Skip to content

Filter Builder

Compose AND/OR queries from typed rules, with a draft you apply.

import { FilterBuilder } from '@youj/react'

Installation & usage

npm i @youj/react

For an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.

Use a Filter Builder where people need to ask real questions of a list: analysts slicing a table, admins finding CRM records, support reading logs. For a few independent filters, use the Data Table toolbar; to type a query as text, use Filter Chips.

import { FilterBuilder, matchFilter, type FilterField, type FilterGroup } from '@youj/react'

const fields: FilterField[] = [
  { id: 'name', label: 'Deal', type: 'text' },
  { id: 'stage', label: 'Stage', type: 'select', options: ['Lead', 'Proposal', 'Won', 'Lost'] },
  { id: 'amount', label: 'Amount', type: 'number' },
  { id: 'closes', label: 'Close date', type: 'date' },
]

export function Deals({ deals }: { deals: Deal[] }) {
  const [filter, setFilter] = useState<FilterGroup>({ type: 'group', combinator: 'and', children: [] })
  return (
    <>
      <FilterBuilder fields={fields} value={filter} onValueChange={setFilter} />
      <DealTable deals={deals.filter((deal) => matchFilter(deal, filter))} />
    </>
  )
}

fields is the schema: an id (the key on a row), a label, a type (text, number, date, select, multiselect or boolean) and, for the two select types, options (strings, or { value, label }). The type decides the operators and the editor:

TypeOperators
textis, is not, contains, starts with, is empty, is not empty
numberis, is not, is greater than, is less than, is between, is empty, is not empty
dateis, is before, is after, is between, is in the last N days, is empty, is not empty
selectis, is not, is any of, is empty, is not empty
multiselecthas any of, has all of, is empty, is not empty
booleanis yes, is no

The value is a FilterGroup: { type: 'group', combinator: 'and' | 'or', children }, where a child is a rule ({ type: 'rule', field, operator, value }) or another group. Dates are YYYY-MM-DD strings. Ids are optional; the builder adds them while editing.

Each rule reads as a sentence of tokens: the field, an operator that suits its type, and the value. A click on a token opens a small menu (a search narrows a list of eight or more) or, for text, numbers and dates, a field; Enter closes it. A new rule opens its field menu, and picking a field or operator that needs a value goes on to the value. The × after a rule, or Delete on any of its tokens, removes it; with a Toaster in the app, a toast offers Undo.

onValueChange is called when Apply succeeds, with the draft. Reset throws the draft away. While the draft differs from the applied filter, the footer says "Changes not applied". A rule that is missing its value is flagged once it has been left, or when Apply refuses it; the first such rule takes focus.

Running the filter

matchFilter(row, group) returns whether a row passes. An empty group and a rule that isn't complete yet don't narrow anything. Text compares without case, dates by day, and numbers as numbers. For rows that aren't flat objects, pass { get: (row, field) => … }; { now } fixes what "in the last N days" counts back from.

describeFilter(group, fields) writes the filter as a sentence, such as Stage is not Lost and (Owner is Ada or Amount > 100000). It takes the labels as a third argument when you need another language.

Reference

Propsfields, value, defaultValue, onValueChange, maxDepth, summary…

Props

FilterBuilder props
PropType
fieldsRequiredFilterField[]

The columns a rule can filter on.

–
valueFilterGroup

The applied filter (controlled). The builder's draft starts from it and returns to it on Reset.

–
defaultValueFilterGroup

The filter applied at first (uncontrolled).

default EMPTY_FILTER

EMPTY_FILTER
onValueChange(value: FilterGroup) => void

Called with the draft when Apply succeeds.

–
maxDepthnumber

How many levels of groups nest, the outer one counting as 1.

default 2

2
summaryboolean

Shows the applied filter as a sentence, or as chips.

default true

true
aria-labelstring

Names the outer group.

default "Filter"

"Filter"
disabledboolean

default false

false
Other props pass through: extends ComponentProps<'div'>.

Data attributes

The root carries data-slot="filter-builder" and data-yj-dirty while the draft differs from the applied filter. Each group is data-slot="filter-group" with data-yj-depth (the outer group is 1) and data-yj-combinator; its and/or token is data-slot="filter-combinator". Each rule is data-slot="filter-rule", with data-invalid while it is flagged; its tokens sit in data-slot="filter-rule-tokens", each data-slot="filter-token" with data-yj-token (field, operator or value) and data-popup-open while its menu is open. The summary's chips are data-slot="filter-builder-chip".

MotionRows enter and leave · menus · reduced motion

Motion

Motion tokens
MomentTokens
Row entersa rule or group fades in from a few px above while the rows below make room (PresenceList)
duration.baseease.enter

220ms · cubic-bezier(0.16, 1, 0.3, 1)

220mscubic-bezier(0.16, 1, 0.3, 1)
Row leavesfades where it stands, then the rest close up
duration.fastease.exit

140ms · cubic-bezier(0.4, 0, 1, 1)

140mscubic-bezier(0.4, 0, 1, 1)
Rows reflowglide to their new places
spring.smooth

0.36s, bounce 0

0.36s, bounce 0
Token menu opensgrows out of its token (yj-pop); instant when opened from the keyboard
duration.fastease.enter

140ms · cubic-bezier(0.16, 1, 0.3, 1)

140mscubic-bezier(0.16, 1, 0.3, 1)
Menu highlightglides between options (Sliding Indicator); keyboard moves land at once
spring.smooth

0.36s, bounce 0

0.36s, bounce 0
Remove buttonfades in on hover or focus of its rule
duration.fastease.standard

140ms · cubic-bezier(0.2, 0, 0, 1)

140mscubic-bezier(0.2, 0, 0, 1)

Reduced motion

Rows appear and leave in one frame, with no glide. Menus open at once and the highlight jumps.

Reduced motion

Nothing moves; the hint, the flags and the announcements still appear.

AccessibilityGroups, keyboard, focus, announcements

Accessibility

  • Every group is a role="group" named for its place: the outer one "Filter" (or aria-label), the nested ones "Group 2". Every rule is a group too ("Rule 1"). Each token is a button named for what it is and what it holds ("Field: Stage", "Operator: is not", "Value: Lost"), so it is heard with its rule; the and/or token is "Combine rules with: and".
  • Tab walks it in reading order: each group's and/or token, each rule's tokens and its remove button, then Add filter, Add group (and a nested group's menu), Reset and Apply. Enter, Space or ArrowDown opens a token. Its menu is a listbox: the arrow keys, Home and End move, a letter jumps, Enter or Space picks, and a search field narrows a long list while the arrows still move. Escape closes and returns focus to the token. The remove button shows on hover and whenever its rule holds focus, and Delete or Backspace on a token removes its rule.
  • Adding a rule opens its field menu. Removing a row moves focus to the next row, else the previous, else the group's Add filter, so focus is never lost to the page; Undo puts the row back and focuses it.
  • Additions, removals, Apply and Reset are announced politely. A rule flagged as incomplete marks its value token aria-invalid, described by "Choose a value", and a refused Apply announces it and focuses the first one.
  • The summary is a labelled group; in chips form it is a list.