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
- 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.
- Where
- 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.
- 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
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the group for assistive tech, such as Invoice filters. |
fields | { key: string; label: string; type: "text" | "number" | "date" | "choice"; options?: { value: string; label: string }[] }[] | none | What 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 }[] } | none | Controlled filter. Values are JSON: text, a number, an ISO date, a list of choices, a [from, to] pair, or null. |
defaultValue | Filter | { combinator: "and", conditions: [] } | Starting filter when uncontrolled. |
onValueChange | (filter: Filter) => void | none | Called with the whole filter after every edit. |
size | "s" | "m" | "m" | Control height; s suits toolbars and side panels. |
empty | string | "No conditions. Every row is shown." | Shown when there are no conditions. |
...rest | ComponentProps<"div"> | none | Every native div attribute, on the group. |
matchesFilter | (row: object, filter: Filter, fields: FilterField[]) => boolean | none | Exported helper that applies a filter to one record on the client. Incomplete conditions are ignored. |
filterOperators | Record<FieldType, { value; label; shape }[]> | none | Exported table of operators per field type, for building the same filter on a server. |
Anatomy
| root | .eq-filter-builder | A div with role group; also the size container for the rows. |
|---|---|---|
| list | .eq-filter-builder-list | An ordered list of conditions. |
| row | .eq-filter-builder-row | One condition. The value moves to its own line under 520px. |
| lead | .eq-filter-builder-lead | Where on the first row, the and or or choice on the second, the word after that. |
| combinator | .eq-filter-builder-combinator | Sets and or or for every condition. |
| control | .eq-filter-builder-control | Shared look of every select and input. |
| field | .eq-filter-builder-field | |
| operator | .eq-filter-builder-operator | |
| value | .eq-filter-builder-value | Holds the input; data-shape is none, single, range or many. |
| input | .eq-filter-builder-input | Text, number, date or choice value. |
| and | .eq-filter-builder-and | The word between the two inputs of a range. |
| choices | .eq-filter-builder-choices | Fieldset of checkboxes for is any of. |
| legend | .eq-filter-builder-legend | Visually hidden name of the choices. |
| choice | .eq-filter-builder-choice | A 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-clear | Shown once there are two or more conditions. |
| empty | .eq-filter-builder-empty | |
| status | .eq-filter-builder-status | Visually hidden live region that announces added and removed conditions. |
Keyboard
| TabShift+Tab | Moves through the and or or choice, then each row's field, operator, value and remove button, then add. |
|---|---|
| ArrowUpArrowDown | Changes the focused select. Native browser behaviour. |
| Space | Toggles the focused choice under is any of. |
| EnterSpace | On 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;
}
}
}