Filters
Dynamic and custom filters
Remote options, paste recognition, session persistence, and existing application state.
Remote options and paste
Owner options load two at a time. Paste ISSUE-123 or alex@example.com, or type them and press Enter. Try #123 to choose between two matching filters.
Link to these filtersApplied values
{
"search": "",
"owner": null,
"created": null,
"issueId": null,
"email": null,
"ticket": null,
"reference": null
}This example searches a simulated asynchronous data source, loads more options, and resolves selected IDs separately. Open a link with ?owner=["sam"] to see label resolution without opening the menu. The example remembers filters in this browser session when there are no explicit filters in the URL.
Paste ISSUE-123 or alex@example.com into search, or type them and press Enter. They become editable chips automatically. These fields use menu: false so the menu stays focused on choices such as owner and date. #123 deliberately matches two fields so you can choose its destination. Unrecognized text stays in search.
Large selections remain applied and are saved in this browser session. The notice explains when the URL cannot reproduce the full selection in another browser.
View source
"use client";
import { useId, useRef, useState } from "react";
import { NuqsAdapter } from "nuqs/adapters/react";
import { defineFilters, filter, remoteOptions } from "@mendylanda/ui/filters";
import { FilterBar } from "@mendylanda/ui/filters";
import { useUrlFilters } from "@mendylanda/ui/filters/nuqs";
import { Checkbox } from "@mendylanda/ui/primitives/checkbox";
import { Label } from "@mendylanda/ui/primitives/label";
import { Button } from "@mendylanda/ui/primitives/button";
const people = [
{ id: "alex", name: "Alex Rivera" },
{ id: "jordan", name: "Jordan Lee" },
{ id: "mendy", name: "Mendy Landa" },
{ id: "sam", name: "Sam Cohen" },
{ id: "taylor", name: "Taylor Morgan" },
];
function waitForOptions(signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const abort = () => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
};
const timer = setTimeout(() => {
signal.removeEventListener("abort", abort);
resolve();
}, 250);
signal.addEventListener("abort", abort, { once: true });
});
}
export function AdvancedFiltersDemo() {
return (
<NuqsAdapter>
<RemoteFilters />
</NuqsAdapter>
);
}
function RemoteFilters() {
const failNext = useRef(false);
const closeId = useId();
const [closeMenuOnApply, setCloseMenuOnApply] = useState(false);
const source = remoteOptions({
scope: "example-people",
params: {},
async search({ query, cursor, signal }) {
await waitForOptions(signal);
if (failNext.current) {
failNext.current = false;
throw new Error("The example request failed. Try again.");
}
const matches = people.filter((person) =>
person.name.toLowerCase().includes(query.toLowerCase()),
);
const start = Number(cursor ?? 0);
return {
items: matches.slice(start, start + 2),
cursor: start + 2 < matches.length ? String(start + 2) : null,
};
},
async resolve({ ids, signal }) {
await waitForOptions(signal);
return people.filter((person) => ids.includes(person.id));
},
getValue: (person) => person.id,
getLabel: (person) => person.name,
});
const definitions = defineFilters({
owner: filter.options({
label: "Owner",
options: source,
suggestion: { value: ["mendy"] },
summary: { mode: "count", limit: 2 },
}),
created: filter.dateRange({ label: "Created date" }),
issueId: filter.tokens({
label: "Issue ID",
menu: false,
recognize: (token) => (/^ISSUE-\d+$/i.test(token) ? [token.toUpperCase()] : undefined),
summary: { mode: "ellipsis", maxWidth: 160 },
}),
email: filter.tokens({
label: "Email",
menu: false,
recognize: (token) => (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(token) ? [token] : undefined),
}),
ticket: filter.tokens({
label: "Ticket",
menu: false,
recognize: (token) => (/^#\d+$/.test(token) ? [token] : undefined),
}),
reference: filter.tokens({
label: "Reference",
menu: false,
recognize: (token) => (/^#\d+$/.test(token) ? [token] : undefined),
}),
});
const filters = useUrlFilters(definitions, {
scope: "advanced-example",
searchKey: "search",
markerKey: "_advanced",
remember: "session",
history: "push",
maxUrlLength: 1000,
});
return (
<section aria-label="Dynamic filters example" className="space-y-3 rounded-md border p-3">
<FilterBar
filters={filters}
searchLabel="Search references"
closeMenuOnApply={closeMenuOnApply}
/>
<div className="flex items-center gap-2">
<Checkbox
className="rounded-sm"
id={closeId}
checked={closeMenuOnApply}
onCheckedChange={(checked) => setCloseMenuOnApply(checked === true)}
/>
<Label htmlFor={closeId} className="text-xs">
Close menu after applying
</Label>
</div>
<p className="text-xs text-muted-foreground">
Owner options load two at a time. Paste ISSUE-123 or alex@example.com, or type them and
press Enter. Try #123 to choose between two matching filters.
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
failNext.current = true;
}}
>
Fail next option request
</Button>
{filters.shareable && (
<a
className="block text-xs underline"
href={filters.ready ? (filters.createLink("/docs/advanced") ?? undefined) : undefined}
>
Link to these filters
</a>
)}
<details>
<summary className="cursor-pointer text-xs">Applied values</summary>
<pre aria-label="Dynamic filter values" className="overflow-auto pt-2 text-xs">
{JSON.stringify({ search: filters.search, ...filters.values }, null, 2)}
</pre>
</details>
</section>
);
}
Existing state and custom editors
Project filters
Applied values
{
"workspaceId": null,
"projectId": null,
"status": [
"active",
"paused"
],
"hasOpenTasks": null,
"hasReviews": null,
"createdAfter": null,
"createdBefore": null,
"tagId": null,
"hasTag": null,
"memberId": null,
"teamSize": null,
"q": null
}Saved view draft
Applied values
{
"workspaceId": null,
"projectId": null,
"status": null,
"hasOpenTasks": null,
"hasReviews": null,
"createdAfter": null,
"createdBefore": null,
"tagId": null,
"hasTag": null,
"memberId": null,
"teamSize": null,
"q": null
}Both sets use the same definitions with independent local state. Created date uses a calendar for a single day or a range while preserving separate start and end fields. Status and Tags group related controls with section dividers, compact Yes/No toggles, and colored tag options. The custom member editor summarizes whole teams when all their members are selected. Changing Workspace updates available projects while retaining existing selections.
Pass an existing persistence callback to useControlledFilters, or keep a saved view draft in local state. Neither has to change the application's backend query shape.
View source
"use client";
import type { EditorContext } from "@mendylanda/ui/filters";
import { useId, useState } from "react";
import { bindFilters, defineFilters, filter, jsonCodec } from "@mendylanda/ui/filters";
import { Checkbox } from "@mendylanda/ui/primitives/checkbox";
import { Label } from "@mendylanda/ui/primitives/label";
import { FilterBar } from "@mendylanda/ui/filters";
import { useControlledFilters } from "@mendylanda/ui/filters";
// Existing application state can keep its own field names and query shape.
interface ProjectFilters {
workspaceId: string[] | null;
projectId: string[] | null;
status: ("active" | "paused" | "archived")[] | null;
hasOpenTasks: "with" | "without" | null;
hasReviews: "with" | "without" | null;
createdAfter: string | null;
createdBefore: string | null;
tagId: string[] | null;
hasTag: "with" | "without" | null;
memberId: string[] | null;
teamSize: [number | null, number | null] | null;
q: string[] | null;
}
const empty: ProjectFilters = {
workspaceId: null,
projectId: null,
status: null,
hasOpenTasks: null,
hasReviews: null,
createdAfter: null,
createdBefore: null,
tagId: null,
hasTag: null,
memberId: null,
teamSize: null,
q: null,
};
const project = bindFilters<ProjectFilters>();
const workspaces = [
{ value: "design", label: "Design" },
{ value: "engineering", label: "Engineering" },
];
const projects = [
{ value: "design-web", label: "Website", workspace: "design" },
{ value: "design-mobile", label: "Mobile app", workspace: "design" },
{ value: "engineering-api", label: "API", workspace: "engineering" },
];
const members = [
{ id: "one", name: "Alex Rivera", team: "Design team" },
{ id: "two", name: "Jordan Lee", team: "Design team" },
{ id: "three", name: "Sam Cohen", team: "Engineering team" },
];
function MemberEditor({ value, setValue }: EditorContext<string[] | null>) {
const id = useId();
return (
<fieldset className="min-w-0 w-full space-y-2">
<legend className="mb-2 text-sm font-medium">Team members</legend>
{members.map((member) => (
<div key={member.id} className="flex items-center gap-2">
<Checkbox
className="rounded-sm"
id={`${id}-${member.id}`}
checked={value?.includes(member.id) ?? false}
onCheckedChange={(checked) => {
const next =
checked === true
? [...(value ?? []), member.id]
: (value?.filter((id) => id !== member.id) ?? []);
setValue(next.length ? next : null);
}}
/>
<Label htmlFor={`${id}-${member.id}`}>{member.name}</Label>
</div>
))}
</fieldset>
);
}
function memberSummary(value: string[] | null) {
const groups = new Map<string, typeof members>();
for (const member of members)
groups.set(member.team, [...(groups.get(member.team) ?? []), member]);
return [...groups.entries()]
.flatMap(([team, members]) =>
members.every((member) => value?.includes(member.id))
? [team]
: members.filter((member) => value?.includes(member.id)).map((member) => member.name),
)
.join(", ");
}
const strings = jsonCodec(
(value): value is string[] | null =>
value === null || (Array.isArray(value) && value.every((item) => typeof item === "string")),
);
function useProjectDefinitions(value: ProjectFilters) {
// An application can supply these arrays from its existing query hooks.
const availableProjects = projects.filter(
(project) => !value.workspaceId?.length || value.workspaceId.includes(project.workspace),
);
return defineFilters({
workspace: project.field(
"workspaceId",
filter.multiSelect({ label: "Workspace", options: workspaces, searchable: true }),
),
project: project.field(
"projectId",
filter.multiSelect({
label: "Project",
options: {
kind: "external",
items: availableProjects,
selectedItems: projects.filter((project) => value.projectId?.includes(project.value)),
},
}),
),
status: project.field(
"status",
filter.multiSelect({
label: "Status",
searchable: false,
options: [
{ value: "active", label: "Active" },
{ value: "paused", label: "Paused" },
{ value: "archived", label: "Archived" },
],
defaultValue: ["active", "paused"],
clearValue: [],
}),
),
tasks: project.field(
"hasOpenTasks",
filter.select({
label: "Open tasks",
searchable: false,
menuLayout: "inline",
renderSummary: (value) => (value === "with" ? "Open tasks" : "No open tasks"),
options: [
{ value: "with", label: "Yes" },
{ value: "without", label: "No" },
],
}),
),
reviews: project.field(
"hasReviews",
filter.select({
label: "Reviews",
searchable: false,
menuLayout: "inline",
renderSummary: (value) => (value === "with" ? "Reviews" : "No reviews"),
options: [
{ value: "with", label: "Yes" },
{ value: "without", label: "No" },
],
}),
),
created: project.composite(["createdAfter", "createdBefore"], {
field: filter.dateRange({ label: "Created date" }),
read: (state) =>
state.createdAfter || state.createdBefore
? { from: state.createdAfter, to: state.createdBefore }
: null,
write: (range) => ({ createdAfter: range?.from ?? null, createdBefore: range?.to ?? null }),
}),
tags: project.field(
"tagId",
filter.options({
label: "Tags",
renderOption: (tag) => (
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: tag.color }}
/>
{tag.label}
</span>
),
options: [
{ value: "priority", label: "Priority", color: "#2563eb" },
{ value: "review", label: "Review", color: "#16a34a" },
],
}),
{
update: (tagId, current) => ({
tagId,
hasTag: current.hasTag === "without" ? null : current.hasTag,
}),
},
),
tagged: project.field(
"hasTag",
filter.select({
label: "Tagged",
searchable: false,
menuLayout: "inline",
renderSummary: (value) => (value === "with" ? "Tagged" : "No tagged"),
options: [
{ value: "with", label: "Yes" },
{ value: "without", label: "No" },
],
}),
{ update: (hasTag) => ({ hasTag, ...(hasTag === "without" ? { tagId: null } : {}) }) },
),
members: project.field(
"memberId",
filter.custom({
label: "Members",
defaultValue: null as string[] | null,
clearValue: null,
codec: strings,
renderEditor: (context) => <MemberEditor {...context} />,
renderSummary: memberSummary,
}),
),
size: project.field("teamSize", filter.numberRange({ label: "Team size" })),
});
}
function ProjectSet({ label, defaults }: { label: string; defaults: ProjectFilters }) {
const [value, setValue] = useState(defaults);
const definitions = useProjectDefinitions(value);
const filters = useControlledFilters({
definitions,
value,
onPatch: (patch) => setValue((current) => ({ ...current, ...patch })),
search: {
read: (current) => current.q?.join(", ") ?? "",
write: (text) => ({
q: text
? text
.split(/[\r\n,]+/)
.map((part) => part.trim())
.filter(Boolean)
: null,
}),
},
});
return (
<section aria-label={label} className="space-y-3 rounded-md border p-3">
<p className="text-sm font-medium">{label}</p>
<FilterBar
filters={filters}
groups={[
{
id: "status-menu",
label: "Status",
fields: ["status", "tasks", "reviews"],
separatorBefore: true,
},
{ id: "tags-menu", label: "Tags", fields: ["tags", "tagged"], separatorBefore: true },
]}
/>
<details>
<summary className="cursor-pointer text-xs">Applied values</summary>
<pre aria-label={`${label} values`} className="overflow-auto pt-2 text-xs">
{JSON.stringify(value, null, 2)}
</pre>
</details>
</section>
);
}
export function ProjectFiltersDemo() {
return (
<div className="space-y-4">
<ProjectSet label="Project filters" defaults={{ ...empty, status: ["active", "paused"] }} />
<ProjectSet label="Saved view draft" defaults={empty} />
</div>
);
}