component — display

Filter Builder

Where [Field] [operator] [Value], one row per condition — the only filter surface that reads as a query rather than as a toolbar, and the only one that can express negation or two conditions on one field. A pill bar or a faceted rail draws one control per field, so a field-keyed map says everything they can; a condition list says the rest. Each row is a field Select, an operator Select scoped to that field's variant (enum: is / is not; text: contains / does not contain / is / starts with; number and date: between / not between), and a value button opening the same FilterPanel every other filter surface uses. A condition with no value yet is inert, and the row says so in words instead of looking applied. The rows share grid tracks so the columns line up as a property rather than by hardcoded widths, and each row becomes its own bordered card below 42rem of container width. Controlled, over FilterField and FilterCondition from lib/data-table — the same list ServerTable reports through onQueryChange and queryToSearchParams writes into a URL.

  • filter
  • condition
  • query
  • operator
  • segment
  • saved-view
  • interactive

03 examples

A list of conditions

Where [Field] [operator] [Value], one row per condition, over the same card grid FilterBar filters. Add a condition and it starts inert — with no value it narrows nothing, and the row says so rather than looking applied.

10 of 10 kiosks

Atrium NorthAtriumlive
Atrium SouthAtriumlive
Lobby DeskLobbydraft
Lobby WindowLobbyoffline
Garage EntryGaragelive
Garage ExitGarageoffline
Rooftop BarRooftopdraft
Rooftop TerraceRooftoplive
Foyer EastFoyerlive
Foyer WestFoyerdraft

Negation, and two conditions on one field

The two things a field-keyed filter map cannot hold, which is the reason this surface needed a model change before it needed a layout.

Two of these three rows are impossible in a field-keyed filter map: is not has no operator to be, and the second condition on Name would overwrite the first. Switch is not to is and the result inverts — the select is the predicate, not a label beside it.
  • Where
  • and
  • and
3 results

3 of 10 kiosks

Lobby DeskLobbydraft
Lobby WindowLobbyoffline
Foyer WestFoyerdraft

The operator travels with the value — into the URL, and into the query a server-driven table reports.

  • Where
8 results
/kiosks?f.status=isNot%3Aoffline
The same list is what ServerTable reports through onQueryChange, so the loader behind it runs the user's actual question rather than the one the operator was dropped from.

source

filter-builder.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { Plus, RotateCcw, X } from "lucide-react"
import { useId, useRef, useState } from "react"
import { Button } from "@/components/button"
import { describeFilter, FilterPanel } from "@/components/filter-bar"
import { FormattedNumber } from "@/components/formatted-number"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover"
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/select"
import {
  conditionOnField,
  type FilterCondition,
  type FilterField,
  type FilterOperator,
  filterOperators,
  isConditionSet,
  newCondition,
} from "@/lib/data-table"
import { cn } from "@/lib/utils"

/**
 * Filter Builder — quebi design system
 *
 * `Where [Field] [operator] [Value]`, one row per condition. The only filter
 * surface that reads as a query rather than as a toolbar, and the only one that
 * can express **negation** or **two conditions on one field** — which is why it
 * could not be built until the model had an operator and a condition list
 * (task #193). A pill bar or a faceted rail draws one control per field, so a
 * field-keyed map is exactly as much as it can say; here the second row on
 * `Name` is a second row, and `is not` is the predicate rather than a label.
 *
 * It is a sibling of `FilterBar` and `FilterRail`, over the same
 * `FilterField[]` and the same `matchesFilter`, and `filterRows(rows, fields,
 * conditions)` in `@/lib/data-table` is still the one line that turns the
 * state into rows. The value cell is `FilterPanel` unchanged — every variant,
 * every validation, the same Clear / Apply pair — hosted in a popover instead
 * of under a pill.
 *
 * **Conditions are joined with AND, and there is no `or`.** A list where each
 * row picks its own conjunction is not a list: `a or b and c` has two readings
 * and the row that would disambiguate them is a bracket. That is a tree, it
 * needs nesting to draw and a nested shape to hold, and neither is what a
 * `FilterCondition[]` is. So the conjunction is stated once, in words, in the
 * first cell of every row after the first.
 */

/**
 * One height for every control in a row — the same `sm` (38px) `FilterBar`
 * pins its pills to, so a builder under a bar is one scale rather than two.
 */
const ROW_SIZE = "sm" as const

export interface FilterBuilderProps {
  fields: FilterField[]
  /** The conditions, in order. Controlled — the builder holds none of its own. */
  conditions: FilterCondition[]
  onChange: (conditions: FilterCondition[]) => void
  /**
   * How many rows the conditions select, for the line under the list. It is
   * the only thing on this surface that says the query did anything, because
   * unlike a pill bar the rows look identical whether they match everything or
   * nothing.
   */
  resultCount?: number
  /** Refuses to add past this many rows. Left out, there is no ceiling. */
  maxConditions?: number
  isLoadingOptions?: boolean
  onSearchOptions?: (fieldId: string, search: string) => void
  onLoadMoreOptions?: (fieldId: string) => void
  /**
   * Names the list of rows. Each row is named by its own controls — "Field",
   * "Operator", the value button that states the field it belongs to — so this
   * names the group they are read inside.
   */
  "aria-label"?: string
  className?: string
}

/**
 * FilterBuilder — a list of conditions, one per row.
 *
 * Controlled: it takes `conditions` and reports the next list through
 * `onChange`. Every edit is a whole new list, so a caller can put the list in a
 * URL, a saved view or a segment without the builder having an opinion about
 * where it lives.
 */
export function FilterBuilder({
  fields,
  conditions,
  onChange,
  resultCount,
  maxConditions,
  isLoadingOptions,
  onSearchOptions,
  onLoadMoreOptions,
  "aria-label": ariaLabel = "Filter conditions",
  className,
}: FilterBuilderProps) {
  const [openValue, setOpenValue] = useState<string | null>(null)
  /*
   * Row identity, generated here because the rows are created here. `useId`
   * gives a prefix that is stable across a server render and its hydration, and
   * the counter is a ref rather than state: bumping it must not re-render, and
   * it must not be re-read during one — two rows added in the same tick have to
   * get two ids.
   */
  const idPrefix = useId()
  const nextId = useRef(0)
  const makeId = () => `${idPrefix}-${nextId.current++}`

  const fieldOf = (fieldId: string) => fields.find((field) => field.id === fieldId)
  const replace = (condition: FilterCondition) =>
    onChange(conditions.map((entry) => (entry.id === condition.id ? condition : entry)))

  const add = () => {
    const field = fields[0]
    if (!field) return
    onChange([...conditions, newCondition(field, makeId())])
  }

  const atCeiling = maxConditions != null && conditions.length >= maxConditions
  const incomplete = conditions.filter((condition) => !isConditionSet(condition)).length

  return (
    <div
      className={cn(
        // A container query, not a breakpoint: this is a panel as often as it
        // is a page, and the viewport does not predict how wide its host is.
        "@container flex flex-col gap-3 print:hidden",
        className,
      )}
    >
      {conditions.length > 0 && (
        /*
         * A grid, and not a `flex flex-wrap` row of fixed widths. Wrapping was
         * the sketch's shape and it failed twice at once: the cells were
         * aligned only because their widths were hardcoded, so the alignment
         * was a coincidence of the 1440px screenshot; and when a cell did wrap
         * it landed flush against the left margin with nothing tying it to the
         * row it belonged to. Sharing the tracks is what makes the alignment a
         * property instead — every row's Field cell is the same Field column,
         * whatever is in it — and below `@2xl` each row opens on its own
         * hairline, which is the same tie stated a different way.
         */
        <ul
          aria-label={ariaLabel}
          className="grid grid-cols-1 gap-3 @2xl:grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.4fr)_auto] @2xl:gap-2"
        >
          {conditions.map((condition, index) => {
            const field = fieldOf(condition.fieldId)
            const variant = field?.variant
            const isSet = isConditionSet(condition)
            const hintId = `${condition.id}-hint`
            return (
              <li
                key={condition.id}
                className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 border-t border-quebi-hairline pt-3 @2xl:col-span-full @2xl:grid-cols-subgrid @2xl:border-0 @2xl:pt-0"
              >
                <span className="quebi-eyebrow col-start-1 row-start-1 @2xl:justify-self-end">
                  {index === 0 ? "Where" : "and"}
                </span>

                <Select
                  aria-label="Field"
                  className="col-start-2 row-start-1"
                  selectedKey={condition.fieldId}
                  onSelectionChange={(key) => {
                    const next = fieldOf(String(key))
                    if (next) replace(conditionOnField(condition, next))
                  }}
                >
                  <SelectTrigger size={ROW_SIZE} />
                  <SelectContent items={fields}>
                    {(item) => <SelectItem id={item.id}>{item.label}</SelectItem>}
                  </SelectContent>
                </Select>

                <Select
                  aria-label="Operator"
                  className="col-span-3 col-start-1 row-start-2 @2xl:col-span-1 @2xl:col-start-3 @2xl:row-start-1"
                  selectedKey={condition.operator}
                  onSelectionChange={(key) =>
                    replace({ ...condition, operator: String(key) as FilterOperator })
                  }
                >
                  <SelectTrigger size={ROW_SIZE} />
                  <SelectContent items={filterOperators(variant)}>
                    {(item) => <SelectItem id={item.id}>{item.label}</SelectItem>}
                  </SelectContent>
                </Select>

                <Popover
                  isOpen={openValue === condition.id}
                  onOpenChange={(isOpen) => setOpenValue(isOpen ? condition.id : null)}
                >
                  <PopoverTrigger
                    size={ROW_SIZE}
                    intent="outline"
                    aria-label={`Value for ${field?.label ?? condition.fieldId}`}
                    aria-describedby={isSet ? undefined : hintId}
                    className={cn(
                      "col-span-3 col-start-1 row-start-3 w-full justify-start font-normal @2xl:col-span-1 @2xl:col-start-4 @2xl:row-start-1",
                      // Field-shaped, so drawn like the boxed Selects beside it:
                      // a hairline frame on the field ground.
                      // An unfinished condition is inert — it lets every row
                      // through — and the sketch drew its `Select…` in the same
                      // ink as a chosen value, so a row that was doing nothing
                      // looked exactly like the one beside it that was. Muted
                      // ink and a dashed edge are the two cheapest ways to say
                      // "there is nothing here yet"; the line below says why it
                      // matters.
                      !isSet && "border-dashed text-quebi-fg-subtle",
                    )}
                  >
                    <span className="truncate">
                      {isSet ? describeFilter(variant, condition.value) : "Select…"}
                    </span>
                  </PopoverTrigger>
                  <PopoverContent placement="bottom start" className="w-80">
                    {field && (
                      <FilterPanel
                        fieldId={field.id}
                        label={field.label}
                        variant={field.variant}
                        value={condition.value}
                        operator={condition.operator}
                        options={field.options}
                        bounds={field.bounds}
                        isLoadingOptions={isLoadingOptions}
                        onSearchOptions={
                          onSearchOptions && ((search) => onSearchOptions(field.id, search))
                        }
                        onLoadMoreOptions={onLoadMoreOptions && (() => onLoadMoreOptions(field.id))}
                        onApply={(value) => replace({ ...condition, value })}
                        /* Clear empties the value and leaves the row — and
                           leaves its operator. The row is still a question
                           about this field; it is the answer that was taken
                           back. Removing the row is the × beside it. */
                        onClear={() =>
                          replace({ ...condition, value: newCondition(field, condition.id).value })
                        }
                        /* Applying is the end of the interaction here twice
                           over: the popover would otherwise sit over the
                           results it just produced *and* over the next
                           condition row, which it covers exactly (task #188). */
                        onClose={() => setOpenValue(null)}
                      />
                    )}
                  </PopoverContent>
                </Popover>

                {/* Framed, as an outline button. A bare × at the end of a row
                    of fields reads as decoration rather than as the control
                    that deletes the row. */}
                <Button
                  intent="outline"
                  size="sq-sm"
                  aria-label={`Remove condition on ${field?.label ?? condition.fieldId}`}
                  onPress={() =>
                    onChange(conditions.filter((entry) => entry.id !== condition.id))
                  }
                  className="col-start-3 row-start-1 justify-self-end @2xl:col-start-5"
                >
                  <X data-slot="icon" aria-hidden="true" />
                </Button>

                {!isSet && (
                  <p
                    id={hintId}
                    className="col-span-3 col-start-1 row-start-4 text-quebi-caption text-quebi-fg-subtle @2xl:col-span-2 @2xl:col-start-4 @2xl:row-start-2"
                  >
                    No value yet — this condition is not narrowing anything.
                  </p>
                )}
              </li>
            )
          })}
        </ul>
      )}

      <div className="flex flex-wrap items-center gap-2">
        <Button intent="ghost" size={ROW_SIZE} onPress={add} isDisabled={atCeiling}>
          <Plus data-slot="icon" aria-hidden="true" />
          {conditions.length === 0 ? "Add a condition" : "Add condition"}
        </Button>
        {/* The count belongs to the whole list, not to a row, and it is the
            only thing here that says what the conditions did. `n of m` rather
            than `n` because a builder's usual failure is a condition that
            matches nothing, which reads as a broken page without the total. */}
        {resultCount != null && conditions.length > 0 && (
          <span className="text-quebi-body-s text-quebi-fg-muted tabular-nums">
            <FormattedNumber value={resultCount} />
            {resultCount === 1 ? " result" : " results"}
            {incomplete > 0 && (
              <span className="text-quebi-fg-subtle">
                {" · "}
                {incomplete === 1 ? "1 condition" : `${incomplete} conditions`} not applied
              </span>
            )}
          </span>
        )}
        {conditions.length > 0 && (
          <Button
            intent="ghost"
            size={ROW_SIZE}
            className="ms-auto"
            onPress={() => onChange([])}
          >
            <RotateCcw data-slot="icon" aria-hidden="true" />
            Reset
          </Button>
        )}
      </div>
    </div>
  )
}