Skip to content
Mendy UI

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 a 15-digit IMEI, a 19–20-digit ICCID, or #123 to try recognition.

Link to these filters
Applied values
{
  "search": "",
  "owner": null,
  "imei": null,
  "iccid": 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.

IMEI and ICCID recognition here uses length-based examples. Supply your application's actual validators for production identifiers. #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 { useRef, useState } from "react";
import { NuqsAdapter } from "nuqs/adapters/react";
import { defineFilters, filter, remoteOptions } from "@/components/ui/filter-definition";
import { FilterBar } from "@/components/ui/filter-bar";
import { useUrlFilters } from "@/components/ui/use-url-filters";
import { Button } from "@/components/ui/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 [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,
      searchable: true,
      suggestion: { value: ["mendy"] },
      summary: { mode: "count", limit: 2 },
    }),
    imei: filter.tokens({
      label: "IMEI",
      recognize: (token) => (/^\d{15}$/.test(token) ? [token] : undefined),
      summary: { mode: "ellipsis", maxWidth: 160 },
    }),
    iccid: filter.tokens({
      label: "ICCID",
      recognize: (token) => (/^\d{19,20}$/.test(token) ? [token] : undefined),
    }),
    ticket: filter.tokens({
      label: "Ticket",
      recognize: (token) => (/^#\d+$/.test(token) ? [token] : undefined),
    }),
    reference: filter.tokens({
      label: "Reference",
      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}
      />
      <label className="flex items-center gap-2 text-xs">
        <input
          type="checkbox"
          checked={closeMenuOnApply}
          onChange={(event) => setCloseMenuOnApply(event.target.checked)}
        />
        Close menu after applying
      </label>
      <p className="text-xs text-muted-foreground">
        Owner options load two at a time. Paste a 15-digit IMEI, a 19–20-digit ICCID, or #123 to try
        recognition.
      </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

Lines filters

Applied values
{
  "carrierId": null,
  "carrierPlanId": null,
  "status": [
    "active",
    "suspended"
  ],
  "hasPendingPort": null,
  "hasPendingSwap": null,
  "activeStart": null,
  "activeEnd": null,
  "tagId": null,
  "hasTag": null,
  "vendorCompanyId": null,
  "groupMemberCount": null,
  "q": null
}

Sourcing draft

Applied values
{
  "carrierId": null,
  "carrierPlanId": null,
  "status": null,
  "hasPendingPort": null,
  "hasPendingSwap": null,
  "activeStart": null,
  "activeEnd": null,
  "tagId": null,
  "hasTag": null,
  "vendorCompanyId": null,
  "groupMemberCount": null,
  "q": null
}

Both sets use the same definitions with independent local state. The example preserves separate state fields for activation dates, combines related controls in the Status and Tags menus, and uses a custom company editor and summary. Changing Carrier updates the available plans while retaining existing selections.

The actual SimCall integration can pass its existing persistence callback to useControlledFilters. A sourcing draft can use the same definitions with a local patch callback. Neither has to change the application's backend query shape.

View source
"use client";

import type { EditorContext } from "@/components/ui/filter-definition";
import { useState } from "react";
import {
  bindFilters,
  defineFilters,
  filter,
  jsonCodec,
} from "@/components/ui/filter-definition";
import { FilterBar } from "@/components/ui/filter-bar";
import { useControlledFilters } from "@/components/ui/use-filters";

// Representative SimCall state. The actual integration imports LineFilters from the application.
interface LineFilters {
  carrierId: string[] | null;
  carrierPlanId: string[] | null;
  status: ("active" | "suspended" | "canceled")[] | null;
  hasPendingPort: "with" | "without" | null;
  hasPendingSwap: "with" | "without" | null;
  activeStart: string | null;
  activeEnd: string | null;
  tagId: string[] | null;
  hasTag: "with" | "without" | null;
  vendorCompanyId: string[] | null;
  groupMemberCount: [number | null, number | null] | null;
  q: string[] | null;
}
const empty: LineFilters = {
  carrierId: null,
  carrierPlanId: null,
  status: null,
  hasPendingPort: null,
  hasPendingSwap: null,
  activeStart: null,
  activeEnd: null,
  tagId: null,
  hasTag: null,
  vendorCompanyId: null,
  groupMemberCount: null,
  q: null,
};
const line = bindFilters<LineFilters>();
const carriers = [
  { value: "north", label: "North" },
  { value: "south", label: "South" },
];
const plans = [
  { value: "north-voice", label: "North voice", carrier: "north" },
  { value: "north-data", label: "North data", carrier: "north" },
  { value: "south-voice", label: "South voice", carrier: "south" },
];
const companies = [
  { id: "one", name: "Company One", vendor: "North vendor" },
  { id: "two", name: "Company Two", vendor: "North vendor" },
  { id: "three", name: "Company Three", vendor: "South vendor" },
];
function CompanyEditor({ value, setValue, apply }: EditorContext<string[] | null>) {
  return (
    <fieldset className="w-56 space-y-2">
      <legend className="mb-2 text-sm font-medium">Vendor companies</legend>
      {companies.map((company) => (
        <label key={company.id} className="flex items-center gap-2 text-sm">
          <input
            type="checkbox"
            checked={value?.includes(company.id) ?? false}
            onChange={(event) => {
              const next = event.target.checked
                ? [...(value ?? []), company.id]
                : (value?.filter((id) => id !== company.id) ?? []);
              setValue(next.length ? next : null);
            }}
          />
          {company.name}
        </label>
      ))}
      <button type="button" onClick={() => apply()} className="text-sm underline">
        Done
      </button>
    </fieldset>
  );
}
function companySummary(value: string[] | null) {
  const groups = new Map<string, typeof companies>();
  for (const company of companies)
    groups.set(company.vendor, [...(groups.get(company.vendor) ?? []), company]);
  return [...groups.entries()]
    .flatMap(([vendor, members]) =>
      members.every((company) => value?.includes(company.id))
        ? [vendor]
        : members.filter((company) => value?.includes(company.id)).map((company) => company.name),
    )
    .join(", ");
}
const strings = jsonCodec(
  (value): value is string[] | null =>
    value === null || (Array.isArray(value) && value.every((item) => typeof item === "string")),
);
function useLineDefinitions(value: LineFilters) {
  // In SimCall these arrays come from its existing query hooks using value.carrierId.
  const availablePlans = plans.filter(
    (plan) => !value.carrierId?.length || value.carrierId.includes(plan.carrier),
  );
  return defineFilters({
    carrier: line.field(
      "carrierId",
      filter.multiSelect({ label: "Carrier", options: carriers, searchable: true }),
    ),
    plan: line.field(
      "carrierPlanId",
      filter.multiSelect({
        label: "Plan",
        options: {
          kind: "external",
          items: availablePlans,
          selectedItems: plans.filter((plan) => value.carrierPlanId?.includes(plan.value)),
        },
        searchable: true,
      }),
    ),
    status: line.field(
      "status",
      filter.multiSelect({
        label: "Status",
        options: [
          { value: "active", label: "Active" },
          { value: "suspended", label: "Suspended" },
          { value: "canceled", label: "Canceled" },
        ],
        defaultValue: ["active", "suspended"],
        clearValue: [],
      }),
    ),
    port: line.field(
      "hasPendingPort",
      filter.select({
        label: "Pending port",
        options: [
          { value: "with", label: "With pending port" },
          { value: "without", label: "Without pending port" },
        ],
      }),
    ),
    swap: line.field(
      "hasPendingSwap",
      filter.select({
        label: "Pending swap",
        options: [
          { value: "with", label: "With pending swap" },
          { value: "without", label: "Without pending swap" },
        ],
      }),
    ),
    activation: line.composite(["activeStart", "activeEnd"], {
      field: filter.dateRange({ label: "Activation date" }),
      read: (state) =>
        state.activeStart || state.activeEnd
          ? { from: state.activeStart, to: state.activeEnd }
          : null,
      write: (range) => ({ activeStart: range?.from ?? null, activeEnd: range?.to ?? null }),
    }),
    tags: line.field(
      "tagId",
      filter.options({
        label: "Tags",
        options: [
          { value: "priority", label: "Priority" },
          { value: "review", label: "Review" },
        ],
      }),
      {
        update: (tagId, current) => ({
          tagId,
          hasTag: current.hasTag === "without" ? null : current.hasTag,
        }),
      },
    ),
    tagged: line.field(
      "hasTag",
      filter.select({
        label: "Tagged",
        options: [
          { value: "with", label: "Has tags" },
          { value: "without", label: "No tags" },
        ],
      }),
      { update: (hasTag) => ({ hasTag, ...(hasTag === "without" ? { tagId: null } : {}) }) },
    ),
    companies: line.field(
      "vendorCompanyId",
      filter.custom({
        label: "Companies",
        defaultValue: null as string[] | null,
        clearValue: null,
        codec: strings,
        renderEditor: (context) => <CompanyEditor {...context} />,
        renderSummary: companySummary,
      }),
    ),
    members: line.field("groupMemberCount", filter.numberRange({ label: "Member count" })),
  });
}
function LineSet({ label, defaults }: { label: string; defaults: LineFilters }) {
  const [value, setValue] = useState(defaults);
  const definitions = useLineDefinitions(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", "port", "swap"] },
          { id: "tags-menu", label: "Tags", fields: ["tags", "tagged"] },
        ]}
      />
      <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 SimCallFiltersDemo() {
  return (
    <div className="space-y-4">
      <LineSet label="Lines filters" defaults={{ ...empty, status: ["active", "suspended"] }} />
      <LineSet label="Sourcing draft" defaults={empty} />
    </div>
  );
}