Data

Filter builder

Rows of field, operator and value joined by and or or, typed per field, that emit a plain JSON filter.

Built on
<select><input type="date">Container queries@starting-style
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
6.8 kB
Dependencies
React
  1. Where
Code
"use client";

import { FilterBuilder } from "@/components/equip/filter-builder";
import type { FilterField } from "@/components/equip/filter-builder";

const fields: FilterField[] = [
  {
    key: "status",
    label: "Status",
    type: "choice",
    options: [
      { value: "paid", label: "Paid" },
      { value: "open", label: "Open" },
      { value: "overdue", label: "Overdue" },
    ],
  },
  { key: "amount", label: "Amount", type: "number" },
];

export default function Demo() {
  return (
    <div style={{ width: 240 }}>
      <FilterBuilder
        label="Invoice filters"
        size="s"
        fields={fields}
        defaultValue={{
          combinator: "and",
          conditions: [
            { id: "c1", field: "status", operator: "is", value: "overdue" },
            { id: "c2", field: "amount", operator: "gte", value: 500 },
          ],
        }}
      />
    </div>
  );
}

Examples

Filtering a list

matchesFilter applies the same JSON on the client. Conditions that are still missing a value are skipped, so the list never empties while someone types.

  1. Where
    Value for condition 1
  • INV-1042Harbour Traders$1,840
  • INV-1044Harbor Dental$960
  • INV-1045Harbour Traders$2,475
  • INV-1046Lumen Studio$610

4 of 6 invoices

Code
"use client";

import { useMemo, useState } from "react";
import { FilterBuilder, matchesFilter } from "@/components/equip/filter-builder";
import type { Filter, FilterField } from "@/components/equip/filter-builder";

type Invoice = { number: string; customer: string; status: string; amount: number; due: string };

const invoices: Invoice[] = [
  { number: "INV-1042", customer: "Harbour Traders", status: "overdue", amount: 1840, due: "2026-10-02" },
  { number: "INV-1043", customer: "Bluebird Cafe", status: "paid", amount: 320, due: "2026-10-09" },
  { number: "INV-1044", customer: "Harbor Dental", status: "open", amount: 960, due: "2026-11-20" },
  { number: "INV-1045", customer: "Harbour Traders", status: "open", amount: 2475, due: "2026-11-28" },
  { number: "INV-1046", customer: "Lumen Studio", status: "overdue", amount: 610, due: "2026-10-30" },
  { number: "INV-1047", customer: "Bluebird Cafe", status: "paid", amount: 145, due: "2026-11-04" },
];

const fields: FilterField[] = [
  { key: "customer", label: "Customer", type: "text" },
  {
    key: "status",
    label: "Status",
    type: "choice",
    options: [
      { value: "paid", label: "Paid" },
      { value: "open", label: "Open" },
      { value: "overdue", label: "Overdue" },
    ],
  },
  { key: "amount", label: "Amount", type: "number" },
  { key: "due", label: "Due date", type: "date" },
];

const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });

export default function Example() {
  const [filter, setFilter] = useState<Filter>({
    combinator: "and",
    conditions: [{ id: "c1", field: "status", operator: "anyOf", value: ["open", "overdue"] }],
  });
  const shown = useMemo(() => invoices.filter((i) => matchesFilter(i, filter, fields)), [filter]);

  return (
    <div style={{ display: "grid", gap: 16, width: "100%", maxWidth: 640 }}>
      <FilterBuilder label="Invoice filters" fields={fields} value={filter} onValueChange={setFilter} />
      <ul style={{ margin: 0, padding: 0, listStyle: "none", fontSize: "var(--eq-text-s)", display: "grid", gap: 4 }}>
        {shown.map((i) => (
          <li key={i.number} style={{ display: "flex", gap: 12, fontVariantNumeric: "tabular-nums" }}>
            <span style={{ width: 72 }}>{i.number}</span>
            <span style={{ flex: 1, minWidth: 0, color: "var(--eq-ink-2)" }}>{i.customer}</span>
            <span>{money.format(i.amount)}</span>
          </li>
        ))}
      </ul>
      <p style={{ margin: 0, fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>
        {shown.length} of {invoices.length} invoices
      </p>
    </div>
  );
}

Sending the filter to a server

The value is plain JSON: ISO dates, numbers, lists of choices and [from, to] pairs. Switch the second row to or to match either condition.

  1. Where
{
  "combinator": "or",
  "conditions": [
    {
      "id": "c1",
      "field": "signedUp",
      "operator": "between",
      "value": [
        "2026-09-01",
        "2026-09-30"
      ]
    },
    {
      "id": "c2",
      "field": "seats",
      "operator": "gt",
      "value": 25
    }
  ]
}
Code
"use client";

import { useState } from "react";
import { FilterBuilder } from "@/components/equip/filter-builder";
import type { Filter, FilterField } from "@/components/equip/filter-builder";

const fields: FilterField[] = [
  { key: "email", label: "Email", type: "text" },
  { key: "signedUp", label: "Signed up", type: "date" },
  { key: "seats", label: "Seats", type: "number" },
  {
    key: "plan",
    label: "Plan",
    type: "choice",
    options: [
      { value: "free", label: "Free" },
      { value: "team", label: "Team" },
      { value: "business", label: "Business" },
    ],
  },
];

export default function Example() {
  const [filter, setFilter] = useState<Filter>({
    combinator: "or",
    conditions: [
      { id: "c1", field: "signedUp", operator: "between", value: ["2026-09-01", "2026-09-30"] },
      { id: "c2", field: "seats", operator: "gt", value: 25 },
    ],
  });

  return (
    <div style={{ display: "grid", gap: 16, width: "100%", maxWidth: 640 }}>
      <FilterBuilder label="Customer segment" fields={fields} value={filter} onValueChange={setFilter} />
      <pre
        style={{
          margin: 0,
          padding: 12,
          maxHeight: 200,
          overflow: "auto",
          borderRadius: "var(--eq-radius-m)",
          background: "var(--eq-tint)",
          font: "var(--eq-text-xs) / 1.5 var(--eq-font-mono)",
        }}
      >
        {JSON.stringify(filter, null, 2)}
      </pre>
    </div>
  );
}

Compact, in a side panel

Size s fits a 300px panel. Below 520px of room the value drops to its own line instead of squeezing the row.

Code
"use client";

import { FilterBuilder } from "@/components/equip/filter-builder";
import type { FilterField } from "@/components/equip/filter-builder";

const fields: FilterField[] = [
  { key: "title", label: "Title", type: "text" },
  {
    key: "priority",
    label: "Priority",
    type: "choice",
    options: [
      { value: "urgent", label: "Urgent" },
      { value: "high", label: "High" },
      { value: "normal", label: "Normal" },
      { value: "low", label: "Low" },
    ],
  },
  { key: "estimate", label: "Estimate", type: "number" },
  { key: "dueOn", label: "Due", type: "date" },
];

export default function Example() {
  return (
    <aside style={{ boxSizing: "border-box", width: "100%", maxWidth: 300, padding: 12, border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-m)" }}>
      <FilterBuilder
        label="Issue filters"
        size="s"
        fields={fields}
        defaultValue={{
          combinator: "and",
          conditions: [
            { id: "c1", field: "title", operator: "contains", value: "checkout" },
            { id: "c2", field: "estimate", operator: "between", value: [2, 8] },
            { id: "c3", field: "dueOn", operator: "isEmpty", value: null },
          ],
        }}
      />
    </aside>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneNames the group for assistive tech, such as Invoice filters.
fields{ key: string; label: string; type: "text" | "number" | "date" | "choice"; options?: { value: string; label: string }[] }[]noneWhat can be filtered. The type picks the operators and the value input; choice fields need options.
value{ combinator: "and" | "or"; conditions: { id: string; field: string; operator: string; value: FilterValue }[] }noneControlled filter. Values are JSON: text, a number, an ISO date, a list of choices, a [from, to] pair, or null.
defaultValueFilter{ combinator: "and", conditions: [] }Starting filter when uncontrolled.
onValueChange(filter: Filter) => voidnoneCalled with the whole filter after every edit.
size"s" | "m""m"Control height; s suits toolbars and side panels.
emptystring"No conditions. Every row is shown."Shown when there are no conditions.
...restComponentProps<"div">noneEvery native div attribute, on the group.
matchesFilter(row: object, filter: Filter, fields: FilterField[]) => booleannoneExported helper that applies a filter to one record on the client. Incomplete conditions are ignored.
filterOperatorsRecord<FieldType, { value; label; shape }[]>noneExported table of operators per field type, for building the same filter on a server.

Anatomy

root.eq-filter-builderA div with role group; also the size container for the rows.
list.eq-filter-builder-listAn ordered list of conditions.
row.eq-filter-builder-rowOne condition. The value moves to its own line under 520px.
lead.eq-filter-builder-leadWhere on the first row, the and or or choice on the second, the word after that.
combinator.eq-filter-builder-combinatorSets and or or for every condition.
control.eq-filter-builder-controlShared look of every select and input.
field.eq-filter-builder-field
operator.eq-filter-builder-operator
value.eq-filter-builder-valueHolds the input; data-shape is none, single, range or many.
input.eq-filter-builder-inputText, number, date or choice value.
and.eq-filter-builder-andThe word between the two inputs of a range.
choices.eq-filter-builder-choicesFieldset of checkboxes for is any of.
legend.eq-filter-builder-legendVisually hidden name of the choices.
choice.eq-filter-builder-choiceA pill label around a native checkbox.
remove.eq-filter-builder-remove
footer.eq-filter-builder-footer
add.eq-filter-builder-add
clear.eq-filter-builder-clearShown once there are two or more conditions.
empty.eq-filter-builder-empty
status.eq-filter-builder-statusVisually hidden live region that announces added and removed conditions.

Keyboard

TabShift+TabMoves through the and or or choice, then each row's field, operator, value and remove button, then add.
ArrowUpArrowDownChanges the focused select. Native browser behaviour.
SpaceToggles the focused choice under is any of.
EnterSpaceOn add, adds a condition and focuses its field. On remove, removes the row and focuses the next one.

Notes

  • Every control is native and named with its row number, such as Operator for condition 2, so each one reads well out of context.
  • Changing the field resets the operator and value; changing the operator keeps the value when it has the same shape.
  • Focus never falls to the page: after a remove it goes to the next row, the previous row, or the add button.
  • One and or or applies to the whole list. For nested groups, compose two builders and combine their JSON yourself.
  • The builder fills the width of its parent, because its rows are laid out with a container query.
  • Number values are stored as numbers and dates as YYYY-MM-DD strings, so the JSON can go straight to a server.
  • Rows lay out with container queries; without them they keep the narrow two-line layout. Date inputs use the browser's own picker.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/filter-builder.tsx552 lines
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { ChangeEvent, ComponentProps } from "react";
import "./filter-builder.css";

export type FilterFieldType = "text" | "number" | "date" | "choice";

export type FilterField = {
  /** The property each row is matched on. */
  key: string;
  label: string;
  type: FilterFieldType;
  /** The values a choice field can take. */
  options?: { value: string; label: string }[];
};

export type FilterOperator =
  | "contains"
  | "notContains"
  | "is"
  | "isNot"
  | "startsWith"
  | "isEmpty"
  | "isNotEmpty"
  | "eq"
  | "neq"
  | "gt"
  | "gte"
  | "lt"
  | "lte"
  | "between"
  | "on"
  | "before"
  | "after"
  | "anyOf";

/** Plain JSON: a string, a number, an ISO date, a list of choices, a [from, to] pair, or null. */
export type FilterValue = string | number | null | (string | number | null)[];

export type FilterCondition = { id: string; field: string; operator: FilterOperator; value: FilterValue };

export type Filter = { combinator: "and" | "or"; conditions: FilterCondition[] };

type Shape = "none" | "single" | "range" | "many";

export type FilterOperatorInfo = { value: FilterOperator; label: string; shape: Shape };

/** The operators each field type offers, in menu order. The first one is picked for a new row. */
export const filterOperators: Record<FilterFieldType, FilterOperatorInfo[]> = {
  text: [
    { value: "contains", label: "contains", shape: "single" },
    { value: "notContains", label: "does not contain", shape: "single" },
    { value: "is", label: "is", shape: "single" },
    { value: "isNot", label: "is not", shape: "single" },
    { value: "startsWith", label: "starts with", shape: "single" },
    { value: "isEmpty", label: "is empty", shape: "none" },
    { value: "isNotEmpty", label: "is not empty", shape: "none" },
  ],
  number: [
    { value: "eq", label: "equals", shape: "single" },
    { value: "neq", label: "does not equal", shape: "single" },
    { value: "gt", label: "greater than", shape: "single" },
    { value: "gte", label: "at least", shape: "single" },
    { value: "lt", label: "less than", shape: "single" },
    { value: "lte", label: "at most", shape: "single" },
    { value: "between", label: "between", shape: "range" },
    { value: "isEmpty", label: "is empty", shape: "none" },
  ],
  date: [
    { value: "on", label: "is on", shape: "single" },
    { value: "before", label: "before", shape: "single" },
    { value: "after", label: "after", shape: "single" },
    { value: "between", label: "between", shape: "range" },
    { value: "isEmpty", label: "is empty", shape: "none" },
  ],
  choice: [
    { value: "is", label: "is", shape: "single" },
    { value: "isNot", label: "is not", shape: "single" },
    { value: "anyOf", label: "is any of", shape: "many" },
  ],
};

function operatorInfo(type: FilterFieldType, operator: FilterOperator) {
  const list = filterOperators[type];
  return list.find((o) => o.value === operator) ?? list[0];
}

function emptyValue(type: FilterFieldType, shape: Shape): FilterValue {
  if (shape === "none") return null;
  if (shape === "many") return [];
  if (shape === "range") return type === "number" ? [null, null] : ["", ""];
  return type === "number" ? null : "";
}

function filled(v: unknown) {
  return v != null && v !== "" && !(typeof v === "number" && Number.isNaN(v));
}

/** True when a condition has everything it needs to be applied. Incomplete ones are skipped. */
export function isConditionComplete(condition: FilterCondition, fields: FilterField[]) {
  const field = fields.find((f) => f.key === condition.field);
  if (!field) return false;
  const { shape } = operatorInfo(field.type, condition.operator);
  const v = condition.value;
  if (shape === "none") return true;
  if (shape === "many") return Array.isArray(v) && v.length > 0;
  if (shape === "range") return Array.isArray(v) && v.length === 2 && filled(v[0]) && filled(v[1]);
  return !Array.isArray(v) && filled(v);
}

function blank(v: unknown) {
  return v == null || v === "" || (Array.isArray(v) && v.length === 0);
}

/** A row value as YYYY-MM-DD, read in local time when it is a Date. */
function isoDay(v: unknown): string | null {
  if (v instanceof Date) {
    if (Number.isNaN(v.getTime())) return null;
    const pad = (n: number) => String(n).padStart(2, "0");
    return `${v.getFullYear()}-${pad(v.getMonth() + 1)}-${pad(v.getDate())}`;
  }
  if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v)) return v.slice(0, 10);
  return null;
}

function test(raw: unknown, type: FilterFieldType, operator: FilterOperator, value: FilterValue): boolean {
  if (operator === "isEmpty") return blank(raw);
  if (operator === "isNotEmpty") return !blank(raw);
  const pair = Array.isArray(value) ? value : [];

  if (type === "number") {
    const n = typeof raw === "number" ? raw : blank(raw) ? NaN : Number(raw);
    if (Number.isNaN(n)) return operator === "neq";
    const x = Number(value);
    switch (operator) {
      case "eq": return n === x;
      case "neq": return n !== x;
      case "gt": return n > x;
      case "gte": return n >= x;
      case "lt": return n < x;
      case "lte": return n <= x;
      case "between": {
        const [a, b] = [Number(pair[0]), Number(pair[1])];
        return n >= Math.min(a, b) && n <= Math.max(a, b);
      }
      default: return false;
    }
  }

  if (type === "date") {
    const day = isoDay(raw);
    if (!day) return false;
    switch (operator) {
      case "on": return day === value;
      case "before": return day < String(value);
      case "after": return day > String(value);
      case "between": {
        const [a, b] = [String(pair[0]), String(pair[1])].sort();
        return day >= a && day <= b;
      }
      default: return false;
    }
  }

  if (type === "choice") {
    const s = raw == null ? "" : String(raw);
    if (operator === "is") return s === value;
    if (operator === "isNot") return s !== value;
    if (operator === "anyOf") return pair.map(String).includes(s);
    return false;
  }

  const s = raw == null ? "" : String(raw).toLocaleLowerCase();
  const needle = String(value).toLocaleLowerCase();
  switch (operator) {
    case "contains": return s.includes(needle);
    case "notContains": return !s.includes(needle);
    case "is": return s === needle;
    case "isNot": return s !== needle;
    case "startsWith": return s.startsWith(needle);
    default: return false;
  }
}

/**
 * Whether a record passes the filter. Incomplete conditions are ignored, and a filter
 * with nothing complete lets every row through.
 */
export function matchesFilter(row: object, filter: Filter, fields: FilterField[]) {
  const record = row as Record<string, unknown>;
  const live = filter.conditions.filter((c) => isConditionComplete(c, fields));
  if (live.length === 0) return true;
  const check = (c: FilterCondition) => {
    const field = fields.find((f) => f.key === c.field);
    return field ? test(record[c.field], field.type, c.operator, c.value) : true;
  };
  return filter.combinator === "or" ? live.some(check) : live.every(check);
}

export type FilterBuilderProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> & {
  /** Names the group, such as "Invoice filters". */
  label: string;
  /** The fields people can filter on. */
  fields: FilterField[];
  value?: Filter;
  defaultValue?: Filter;
  /** Called with the whole filter after every edit. */
  onValueChange?: (filter: Filter) => void;
  /** Control height: s for toolbars and side panels. */
  size?: "s" | "m";
  /** Shown when there are no conditions. */
  empty?: string;
};

const START: Filter = { combinator: "and", conditions: [] };

export function FilterBuilder({
  label,
  fields,
  value,
  defaultValue = START,
  onValueChange,
  size = "m",
  empty = "No conditions. Every row is shown.",
  className,
  ...rest
}: FilterBuilderProps) {
  const uid = useId();
  const rootRef = useRef<HTMLDivElement>(null);
  const counter = useRef(0);
  const focusNext = useRef<string | null>(null);
  const [inner, setInner] = useState<Filter>(defaultValue);
  const [status, setStatus] = useState("");
  const filter = value ?? inner;

  useEffect(() => {
    const target = focusNext.current;
    if (!target) return;
    focusNext.current = null;
    rootRef.current?.querySelector<HTMLElement>(target)?.focus();
  });

  function commit(next: Filter) {
    if (value === undefined) setInner(next);
    onValueChange?.(next);
  }

  function newId() {
    const taken = new Set(filter.conditions.map((c) => c.id));
    let id = "";
    do id = `c${++counter.current}`;
    while (taken.has(id));
    return id;
  }

  function fresh(field: FilterField, id: string): FilterCondition {
    const op = filterOperators[field.type][0];
    return { id, field: field.key, operator: op.value, value: emptyValue(field.type, op.shape) };
  }

  function update(id: string, patch: (c: FilterCondition) => FilterCondition) {
    commit({ ...filter, conditions: filter.conditions.map((c) => (c.id === id ? patch(c) : c)) });
  }

  function add() {
    if (fields.length === 0) return;
    const condition = fresh(fields[0], newId());
    commit({ ...filter, conditions: [...filter.conditions, condition] });
    focusNext.current = `[data-condition="${condition.id}"] [data-control="field"]`;
    setStatus(`Condition ${filter.conditions.length + 1} added`);
  }

  function remove(id: string) {
    const list = filter.conditions;
    const i = list.findIndex((c) => c.id === id);
    const neighbour = list[i + 1] ?? list[i - 1];
    commit({ ...filter, conditions: list.filter((c) => c.id !== id) });
    focusNext.current = neighbour ? `[data-condition="${neighbour.id}"] [data-control="field"]` : "[data-control=\"add\"]";
    setStatus(`Condition ${i + 1} removed`);
  }

  function clear() {
    commit({ ...filter, conditions: [] });
    focusNext.current = "[data-control=\"add\"]";
    setStatus("All conditions removed");
  }

  function changeField(c: FilterCondition, key: string) {
    const field = fields.find((f) => f.key === key);
    if (field) update(c.id, () => fresh(field, c.id));
  }

  function changeOperator(c: FilterCondition, type: FilterFieldType, operator: FilterOperator) {
    const before = operatorInfo(type, c.operator).shape;
    const after = operatorInfo(type, operator).shape;
    update(c.id, (x) => ({ ...x, operator, value: before === after ? x.value : emptyValue(type, after) }));
  }

  const conditions = filter.conditions;

  return (
    <div
      role="group"
      aria-label={label}
      {...rest}
      ref={rootRef}
      className={["eq-filter-builder", className].filter(Boolean).join(" ")}
      data-size={size}
    >
      {conditions.length === 0 ? (
        <p className="eq-filter-builder-empty">{empty}</p>
      ) : (
        <ol className="eq-filter-builder-list">
          {conditions.map((c, i) => {
            const field = fields.find((f) => f.key === c.field) ?? fields[0];
            const n = i + 1;
            const info = operatorInfo(field.type, c.operator);
            return (
              <li key={c.id} className="eq-filter-builder-row" data-condition={c.id}>
                <span className="eq-filter-builder-lead">
                  {i === 0 ? (
                    "Where"
                  ) : i === 1 ? (
                    <select
                      className="eq-filter-builder-control eq-filter-builder-combinator"
                      aria-label="Match all or any conditions"
                      value={filter.combinator}
                      onChange={(e) => commit({ ...filter, combinator: e.currentTarget.value === "or" ? "or" : "and" })}
                    >
                      <option value="and">and</option>
                      <option value="or">or</option>
                    </select>
                  ) : (
                    filter.combinator
                  )}
                </span>
                <select
                  className="eq-filter-builder-control eq-filter-builder-field"
                  data-control="field"
                  aria-label={`Field for condition ${n}`}
                  value={field.key}
                  onChange={(e) => changeField(c, e.currentTarget.value)}
                >
                  {fields.map((f) => (
                    <option key={f.key} value={f.key}>
                      {f.label}
                    </option>
                  ))}
                </select>
                <select
                  className="eq-filter-builder-control eq-filter-builder-operator"
                  aria-label={`Operator for condition ${n}`}
                  value={info.value}
                  onChange={(e) => changeOperator(c, field.type, e.currentTarget.value as FilterOperator)}
                >
                  {filterOperators[field.type].map((o) => (
                    <option key={o.value} value={o.value}>
                      {o.label}
                    </option>
                  ))}
                </select>
                <span className="eq-filter-builder-value" data-shape={info.shape}>
                  <ValueInput
                    key={`${field.key}:${info.shape}`}
                    id={`${uid}${c.id}`}
                    n={n}
                    field={field}
                    shape={info.shape}
                    value={c.value}
                    onChange={(v) => update(c.id, (x) => ({ ...x, value: v }))}
                  />
                </span>
                <button
                  type="button"
                  className="eq-filter-builder-remove"
                  aria-label={`Remove condition ${n}`}
                  title="Remove condition"
                  onClick={() => remove(c.id)}
                >
                  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
                    <path d="m5 5 6 6m0-6-6 6" />
                  </svg>
                </button>
              </li>
            );
          })}
        </ol>
      )}
      <div className="eq-filter-builder-footer">
        <button type="button" className="eq-filter-builder-add" data-control="add" onClick={add} disabled={fields.length === 0}>
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
            <path d="M8 3.5v9M3.5 8h9" />
          </svg>
          Add condition
        </button>
        {conditions.length > 1 && (
          <button type="button" className="eq-filter-builder-clear" onClick={clear}>
            Clear all
          </button>
        )}
      </div>
      <span className="eq-filter-builder-status" role="status">
        {status}
      </span>
    </div>
  );
}

function ValueInput({
  id,
  n,
  field,
  shape,
  value,
  onChange,
}: {
  id: string;
  n: number;
  field: FilterField;
  shape: Shape;
  value: FilterValue;
  onChange: (value: FilterValue) => void;
}) {
  if (shape === "none") return null;
  const pair = Array.isArray(value) ? value : [null, null];
  const name = `Value for condition ${n}`;

  if (shape === "many") {
    const chosen = Array.isArray(value) ? value.map(String) : [];
    return (
      <fieldset className="eq-filter-builder-choices">
        <legend className="eq-filter-builder-legend">{name}</legend>
        {(field.options ?? []).map((o) => (
          <label key={o.value} className="eq-filter-builder-choice">
            <input
              type="checkbox"
              checked={chosen.includes(o.value)}
              onChange={(e) =>
                onChange(
                  e.currentTarget.checked
                    ? (field.options ?? []).map((x) => x.value).filter((v) => v === o.value || chosen.includes(v))
                    : chosen.filter((v) => v !== o.value),
                )
              }
            />
            <span>{o.label}</span>
          </label>
        ))}
      </fieldset>
    );
  }

  if (field.type === "choice") {
    return (
      <select
        className="eq-filter-builder-control eq-filter-builder-input"
        aria-label={name}
        value={typeof value === "string" ? value : ""}
        onChange={(e) => onChange(e.currentTarget.value)}
      >
        <option value="" disabled>
          Choose…
        </option>
        {(field.options ?? []).map((o) => (
          <option key={o.value} value={o.value}>
            {o.label}
          </option>
        ))}
      </select>
    );
  }

  if (shape === "range") {
    const set = (i: 0 | 1, v: string | number | null) => onChange(i === 0 ? [v, pair[1] ?? null] : [pair[0] ?? null, v]);
    return (
      <>
        <Single id={`${id}a`} type={field.type} label={`From, condition ${n}`} value={pair[0] ?? null} onChange={(v) => set(0, v)} />
        <span className="eq-filter-builder-and" aria-hidden="true">
          and
        </span>
        <Single id={`${id}b`} type={field.type} label={`To, condition ${n}`} value={pair[1] ?? null} onChange={(v) => set(1, v)} />
      </>
    );
  }

  return (
    <Single
      id={id}
      type={field.type}
      label={name}
      value={Array.isArray(value) ? null : value}
      onChange={onChange}
    />
  );
}

function Single({
  id,
  type,
  label,
  value,
  onChange,
}: {
  id: string;
  type: FilterFieldType;
  label: string;
  value: string | number | null;
  onChange: (value: string | number | null) => void;
}) {
  // A number field keeps its own text, so "1." or "-" survive while being typed.
  const [text, setText] = useState(value == null ? "" : String(value));
  const parsed = text.trim() === "" || Number.isNaN(Number(text)) ? null : Number(text);
  if (type === "number" && parsed !== value && !(parsed === null && value === null)) {
    const next = value == null ? "" : String(value);
    if (next !== text) setText(next);
  }

  if (type === "number") {
    return (
      <input
        id={id}
        className="eq-filter-builder-control eq-filter-builder-input"
        type="text"
        inputMode="decimal"
        autoComplete="off"
        aria-label={label}
        placeholder="0"
        value={text}
        onChange={(e: ChangeEvent<HTMLInputElement>) => {
          const raw = e.currentTarget.value;
          setText(raw);
          const n = raw.trim() === "" ? null : Number(raw);
          onChange(n == null || Number.isNaN(n) ? null : n);
        }}
      />
    );
  }

  return (
    <input
      id={id}
      className="eq-filter-builder-control eq-filter-builder-input"
      type={type === "date" ? "date" : "text"}
      autoComplete="off"
      aria-label={label}
      placeholder={type === "date" ? undefined : "Value"}
      value={typeof value === "string" ? value : value == null ? "" : String(value)}
      onChange={(e) => onChange(e.currentTarget.value)}
    />
  );
}
components/equip/filter-builder.css338 lines
@layer equip {
  .eq-filter-builder {
    --_h: var(--eq-h-m);
    --_pad: var(--eq-space-3);
    --_radius: var(--eq-radius-m);
    --_font: var(--eq-text-m);

    position: relative;
    box-sizing: border-box;
    display: grid;
    gap: var(--eq-space-2);
    width: 100%;
    min-width: 0;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--_font) / 1.3 var(--eq-font);
    /* Rows lay out by the space the builder has, not the viewport. A size container
       cannot be sized by its content, so the builder always fills its parent's width. */
    container-type: inline-size;
  }

  .eq-filter-builder[data-size="s"] {
    --_h: var(--eq-h-s);
    --_pad: var(--eq-space-2);
    --_radius: var(--eq-radius-s);
    --_font: var(--eq-text-s);
  }

  .eq-filter-builder-list {
    display: grid;
    gap: var(--eq-space-2);
    margin: 0;
    padding: 0;
    list-style: none;
  }

  /* Narrow: lead, field, operator and remove on one line; the value takes the line below. */
  .eq-filter-builder-row {
    display: grid;
    grid-template-columns: 4.25em minmax(0, 1fr) minmax(0, 1fr) var(--_h);
    grid-template-areas:
      "lead field operator remove"
      ". value value .";
    align-items: center;
    gap: var(--eq-space-1);
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      translate var(--eq-dur-move) var(--eq-spring);
  }

  @starting-style {
    .eq-filter-builder-row {
      opacity: 0;
      translate: 0 -4px;
    }
  }

  /* No value to enter: the row is a single line. */
  .eq-filter-builder-row:has(> .eq-filter-builder-value[data-shape="none"]) {
    grid-template-areas: "lead field operator remove";
  }

  @container (min-width: 520px) {
    .eq-filter-builder-row,
    .eq-filter-builder-row:has(> .eq-filter-builder-value[data-shape="none"]) {
      grid-template-columns: 4.5em minmax(7em, 1fr) minmax(8em, 1fr) minmax(10em, 1.6fr) var(--_h);
      grid-template-areas: "lead field operator value remove";
      gap: var(--eq-space-2);
    }
  }

  .eq-filter-builder-lead {
    grid-area: lead;
    min-width: 0;
    color: var(--eq-ink-2);
    font-size: var(--eq-text-s);
  }

  .eq-filter-builder-field {
    grid-area: field;
  }

  .eq-filter-builder-operator {
    grid-area: operator;
  }

  .eq-filter-builder-value {
    grid-area: value;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--eq-space-1);
    min-width: 0;
  }

  .eq-filter-builder-value[data-shape="none"] {
    display: none;
  }

  .eq-filter-builder-value > .eq-filter-builder-input {
    flex: 1 1 6em;
  }

  /* The two ends of a range share a line for as long as each keeps about four digits. */
  .eq-filter-builder-value[data-shape="range"] > .eq-filter-builder-input {
    flex-basis: 3.5em;
  }

  .eq-filter-builder-value[data-shape="range"] > .eq-filter-builder-input[type="date"] {
    flex-basis: 7.5em;
  }

  .eq-filter-builder-control {
    appearance: none;
    box-sizing: border-box;
    width: 100%;
    min-width: 0;
    height: var(--_h);
    margin: 0;
    padding-inline: var(--_pad);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--_radius);
    background: var(--eq-raised);
    color: var(--eq-ink);
    font: inherit;
    font-variant-numeric: tabular-nums;
    text-overflow: ellipsis;
    transition: border-color var(--eq-dur) var(--eq-ease);
  }

  select.eq-filter-builder-control {
    padding-inline-end: calc(var(--_pad) + 12px);
    /* The chevron is drawn with two gradients, so it follows the ink colour in both themes. */
    background:
      linear-gradient(45deg, transparent 50%, var(--eq-ink-2) 50%) right calc(var(--_pad) / 2 + 9px) center / 4px 4px no-repeat,
      linear-gradient(-45deg, transparent 50%, var(--eq-ink-2) 50%) right calc(var(--_pad) / 2 + 5px) center / 4px 4px no-repeat,
      var(--eq-raised);
    cursor: pointer;
  }

  select.eq-filter-builder-control:dir(rtl) {
    background-position:
      left calc(var(--_pad) / 2 + 5px) center,
      left calc(var(--_pad) / 2 + 9px) center,
      0 0;
  }

  .eq-filter-builder-control option {
    background: var(--eq-raised);
    color: var(--eq-ink);
  }

  select.eq-filter-builder-combinator {
    padding-inline: var(--eq-space-1) calc(var(--eq-space-1) + 14px);
    background-position:
      right 10px center,
      right 6px center,
      0 0;
    color: var(--eq-ink-2);
    font-size: var(--eq-text-s);
  }

  .eq-filter-builder-input::placeholder {
    color: var(--eq-ink-3);
  }

  .eq-filter-builder-input[type="date"] {
    padding-inline-end: var(--eq-space-1);
  }

  .eq-filter-builder-and {
    color: var(--eq-ink-2);
    font-size: var(--eq-text-s);
  }

  .eq-filter-builder-choices {
    display: flex;
    flex-wrap: wrap;
    gap: var(--eq-space-1);
    min-width: 0;
    margin: 0;
    padding: 0;
    border: 0;
  }

  .eq-filter-builder-legend {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .eq-filter-builder-choice {
    display: inline-flex;
    align-items: center;
    gap: var(--eq-space-1);
    box-sizing: border-box;
    height: var(--_h);
    padding-inline: var(--eq-space-2);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-full);
    background: var(--eq-raised);
    font-size: var(--eq-text-s);
    cursor: pointer;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      border-color var(--eq-dur) var(--eq-ease);
  }

  .eq-filter-builder-choice:has(:checked) {
    border-color: var(--eq-solid);
    background: var(--eq-tint-strong);
  }

  .eq-filter-builder-choice > input {
    width: 14px;
    height: 14px;
    margin: 0;
    accent-color: var(--eq-solid);
    cursor: pointer;
  }

  .eq-filter-builder-remove,
  .eq-filter-builder-add,
  .eq-filter-builder-clear {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--eq-space-1);
    box-sizing: border-box;
    height: var(--_h);
    margin: 0;
    border: 0;
    border-radius: var(--_radius);
    background: none;
    color: var(--eq-ink-2);
    font: inherit;
    font-size: var(--eq-text-s);
    cursor: pointer;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-filter-builder-remove {
    grid-area: remove;
    width: var(--_h);
    padding: 0;
  }

  .eq-filter-builder-add,
  .eq-filter-builder-clear {
    padding-inline: var(--eq-space-2);
  }

  .eq-filter-builder-add {
    color: var(--eq-ink);
    font-weight: var(--eq-weight-strong);
  }

  :is(.eq-filter-builder-remove, .eq-filter-builder-add) svg {
    flex: none;
    width: 16px;
    height: 16px;
  }

  :is(.eq-filter-builder-remove, .eq-filter-builder-add, .eq-filter-builder-clear):active {
    scale: var(--eq-press);
  }

  .eq-filter-builder-add:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }

  @media (hover: hover) {
    .eq-filter-builder-control:hover {
      border-color: var(--eq-ink-3);
    }

    :is(.eq-filter-builder-remove, .eq-filter-builder-add:enabled, .eq-filter-builder-clear):hover {
      background: var(--eq-tint);
      color: var(--eq-ink);
    }

    .eq-filter-builder-remove:hover {
      color: var(--eq-danger);
    }
  }

  :is(
      .eq-filter-builder-control,
      .eq-filter-builder-remove,
      .eq-filter-builder-add,
      .eq-filter-builder-clear
    ):focus-visible,
  /* Chrome's calendar button inside a date input takes focus without the input matching
     :focus-visible, so the ring follows focus anywhere inside the input. */
  .eq-filter-builder-input[type="date"]:focus-within,
  .eq-filter-builder-choice:has(:focus-visible) {
    outline: 2px solid var(--eq-focus);
    outline-offset: 1px;
  }

  .eq-filter-builder-choice > input:focus-visible {
    outline: none;
  }

  .eq-filter-builder-footer {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--eq-space-1);
  }

  .eq-filter-builder-empty {
    margin: 0;
    color: var(--eq-ink-2);
    font-size: var(--eq-text-s);
  }

  .eq-filter-builder-status {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  @media (forced-colors: active) {
    .eq-filter-builder-choice:has(:checked) {
      border-width: 2px;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index