Skip to content
Mendy UI

Filters

Dynamic and custom filters

Remote options, paste recognition, session persistence, and existing application state.

On this page

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 filters
Applied 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>
  );
}