Filter

Narrow a column to some of its values: those that occur, a range between two bounds, or a condition the application defines. Reach for it when people look for records by a field rather than by free text.

import { columnFilter, rowFilter } from "@umriss-ui/table";

Every condition stands in the table toolbar with the count of matches, a cross to lift it and “Reset” for all. A click on a condition opens its filter again.

An absent value matches no condition. The list filter offers only values the pre-filter admits, so it never gives away rows a user may not see.

Examples

Choose from the values that occur

filter="list" offers the values found in the column, an absent one included; each condition then stands in the table toolbar.

import { useTable } from "@umriss-ui/table";

interface Incident {
  id: string;
  service: string;
  assignee: string | null;
}

const INCIDENTS: Incident[] = [
  { id: "INC-1048", service: "Checkout", assignee: "Jonas Keller" },
  { id: "INC-1047", service: "Webhooks", assignee: null },
  { id: "INC-1046", service: "Image service", assignee: "Tomasz Nowak" },
  { id: "INC-1045", service: "Sign-in", assignee: "Ada Mwangi" },
  { id: "INC-1041", service: "Checkout", assignee: null },
];

export default function ListFilter() {
  const { Table, Column } = useTable(INCIDENTS, { rowKey: (i) => i.id });

  return (
    <Table ariaLabel="Incidents">
      <Column value="id" label="Incident" rowHeader />
      <Column value="service" label="Service" filter="list" />
      <Column value="assignee" label="Assignee" filter="list" />
    </Table>
  );
}

Keep values between two bounds

filter="range" gives numbers and dates two inclusive bounds, either left open; date bounds are whole calendar days.

import { useTable } from "@umriss-ui/table";

interface Invoice {
  id: string;
  supplier: string;
  amount: number;
  due: Date;
}

const INVOICES: Invoice[] = [
  { id: "INV-26-0318", supplier: "Brandlow Office Supply", amount: 1951.24, due: new Date(2026, 3, 15) },
  { id: "INV-26-0317", supplier: "Kettering & Shaw Events", amount: 15_110, due: new Date(2026, 3, 12) },
  { id: "INV-26-0309", supplier: "Nimbrel Software", amount: 7288, due: new Date(2026, 3, 8) },
  { id: "INV-26-0302", supplier: "Fenwright Legal", amount: 3770, due: new Date(2026, 2, 16, 23, 45) },
  { id: "INV-26-0226", supplier: "Corrin Travel", amount: 8117.2, due: new Date(2026, 2, 12) },
  { id: "INV-26-0221", supplier: "Stellbrook Consulting", amount: 8700, due: new Date(2026, 2, 23) },
];

export default function RangeFilter() {
  const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id });

  return (
    <Table ariaLabel="Invoices">
      <Column value="id" label="Invoice" rowHeader />
      <Column value="supplier" label="Supplier" />
      <Column value="amount" label="Amount (€)" format={{ decimals: 2 }} filter="range" aggregate="sum" />
      <Column value="due" label="Due" filter="range" format="date" />
    </Table>
  );
}

Write the smallest filter of your own

columnFilter takes matches, describe and the Input for the panel; the header funnel, the panel and the condition come from the table.

import { Checkbox } from "@umriss-ui/core";
import { columnFilter, useTable } from "@umriss-ui/table";

/* Fixed so the example looks the same every day; an application would
   write `new Date()`. `setCondition(null)` lifts the condition. */
const TODAY = new Date(2026, 2, 17);

const overdue = columnFilter<Date, true>({
  matches: (due) => due.getTime() < TODAY.getTime(),
  Input: ({ condition, setCondition }) => (
    <Checkbox label="Overdue only" checked={condition === true} onChange={(event) => setCondition(event.target.checked ? true : null)} />
  ),
  describe: () => "overdue",
});

interface Invoice {
  id: string;
  supplier: string;
  due: Date;
}

const INVOICES: Invoice[] = [
  { id: "INV-26-0318", supplier: "Brandlow Office Supply", due: new Date(2026, 3, 15) },
  { id: "INV-26-0309", supplier: "Nimbrel Software", due: new Date(2026, 3, 8) },
  { id: "INV-26-0226", supplier: "Corrin Travel", due: new Date(2026, 2, 12) },
  { id: "INV-26-0302", supplier: "Fenwright Legal", due: new Date(2026, 2, 16) },
  { id: "INV-26-0221", supplier: "Stellbrook Consulting", due: new Date(2026, 2, 23) },
];

export default function OwnFilterMinimal() {
  const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id });

  return (
    <Table ariaLabel="Invoices">
      <Column value="id" label="Invoice" rowHeader />
      <Column value="supplier" label="Supplier" />
      <Column value="due" label="Due" format="date" filter={overdue} />
    </Table>
  );
}

Offer conditions of your own

A filter from columnFilter<number, …> fits every number column of every table; an absent value matches none of its conditions.

import { RadioGroup } from "@umriss-ui/core";
import { columnFilter, useTable } from "@umriss-ui/table";

type Load = "part-time" | "full-time";

const LOADS = [
  { value: "part-time", label: "Part-time – under 32 h" },
  { value: "full-time", label: "Full-time – 32 h and more" },
] as const;

const workload = columnFilter<number, Load>({
  matches: (hours, load) => (load === "part-time" ? hours < 32 : hours >= 32),
  Input: ({ condition, setCondition, column }) => (
    <RadioGroup aria-label={column.label} size="sm" options={LOADS} value={condition} onChange={(load) => setCondition(load)} />
  ),
  describe: (load) => load,
});

interface Person {
  id: string;
  name: string;
  role: string;
  capacity: number | null;
}

const PEOPLE: Person[] = [
  { id: "maya", name: "Maya Lindgren", role: "Product manager", capacity: 32 },
  { id: "arjun", name: "Arjun Mehta", role: "Developer", capacity: 40 },
  { id: "noah", name: "Noah Fischer", role: "Designer", capacity: 24 },
  { id: "kofi", name: "Kofi Mensah", role: "Developer", capacity: 32 },
  { id: "david", name: "David Kowalski", role: "QA engineer", capacity: 20 },
  { id: "new", name: "Starts in April", role: "Developer", capacity: null },
];

export default function OwnFilter() {
  const { Table, Column } = useTable(PEOPLE, { rowKey: (p) => p.id });

  return (
    <Table ariaLabel="Team">
      <Column value="name" label="Person" rowHeader />
      <Column value="role" label="Role" />
      <Column value="capacity" label="Hours a week" filter={workload} />
    </Table>
  );
}

Set a condition from outside the table

t.setFilter sets a condition from a quick filter or a link, null lifts it; t.view carries it for initialView to restore.

import { Badge, Button, Stack, Text } from "@umriss-ui/core";
import { Search, Toolbar, useTable } from "@umriss-ui/table";

const TONE = { delivered: "success", "out for delivery": "accent", "failed attempt": "danger" } as const;

type Status = keyof typeof TONE;

interface Shipment {
  id: string;
  customer: string;
  status: Status;
}

const SHIPMENTS: Shipment[] = [
  { id: "FP-1004210", customer: "Holloway Garden Supplies", status: "delivered" },
  { id: "FP-1004223", customer: "Oakridge Pharmacy", status: "failed attempt" },
  { id: "FP-1004236", customer: "Brixley Cycles", status: "out for delivery" },
  { id: "FP-1004249", customer: "Tamsin's Bakery", status: "delivered" },
  { id: "FP-1004262", customer: "Pellham Hardware", status: "failed attempt" },
  { id: "FP-1004275", customer: "Greywick Studio", status: "out for delivery" },
];

const QUICK_FILTERS: readonly Status[] = ["out for delivery", "failed attempt"];

/* The table holds the condition; the buttons only ask it to change and read
   `t.filter` to show which one stands. That is why the column's funnel, the
   chip in the toolbar, "Reset" and `t.view` all agree with them without a
   line of synchronisation. `setFilter` takes the
   condition of the column's filter - for `filter="list"` the chosen values -
   and passes over a column that has none, with a warning in development.
   Something the user may lift belongs here; `preFilter` is for rows the user
   may not see at all. A restriction by no column, or by several fields at
   once, is a row filter (the examples below). */
export default function ConditionsFromOutside() {
  const t = useTable(SHIPMENTS, { rowKey: (s) => s.id });
  const { Table, Column } = t;

  return (
    <Stack gap={3}>
      <Stack direction="row" gap={2} wrap>
        {QUICK_FILTERS.map((status) => {
          const active = JSON.stringify(t.filter.status) === JSON.stringify([status]);
          return (
            <Button
              key={status}
              size="sm"
              variant={active ? "primary" : "secondary"}
              aria-pressed={active}
              onClick={() => t.setFilter("status", [status])}
            >
              {SHIPMENTS.filter((s) => s.status === status).length} {status}
            </Button>
          );
        })}
        <Button size="sm" variant="ghost" onClick={() => t.setFilter("status", null)}>
          All statuses
        </Button>
      </Stack>

      <Table ariaLabel="Shipments">
        <Toolbar>
          <Search placeholder="Shipment or customer" />
        </Toolbar>
        <Column value="id" label="Shipment" rowHeader />
        <Column value="customer" label="Customer" />
        <Column value="status" label="Status" filter="list">
          {(status) => <Badge tone={TONE[status]}>{status}</Badge>}
        </Column>
      </Table>

      <Stack gap={1}>
        <Text as="span" size="sm" tone="secondary">
          What the application would keep
        </Text>
        <Text as="code" size="sm" mono>
          {JSON.stringify(t.view)}
        </Text>
      </Stack>
    </Stack>
  );
}

Filter by a field no column shows

A row filter asks the whole row; a control of one's own sets it with t.setFilter and reads it with t.conditionOf. The table holds the choice, counts it and resets it - no column needed.

import { MultiSelect } from "@umriss-ui/core";
import { Pagination, Search, Toolbar, rowFilter, useTable } from "@umriss-ui/table";

/* Data from the logistics world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(10, 30);

interface Vehicle {
  id: string;
  plate: string;
  type: "van" | "e-van" | "truck";
  /** Payload, in kg. */
  capacity: number;
  depot: string;
}

const VEHICLES: readonly Vehicle[] = [
  { id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
  { id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
  { id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
  { id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
  { id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
  { id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
  { id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
  { id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];

const DRIVERS = [
  { id: "d1", name: "Martin Hale", depot: "north" },
  { id: "d2", name: "Nadia Petrova", depot: "north" },
  { id: "d3", name: "Owen Carter", depot: "north" },
  { id: "d4", name: "Lucia Romero", depot: "river" },
  { id: "d5", name: "Ben Adeyemi", depot: "river" },
  { id: "d6", name: "Hanna Berg", depot: "east" },
  { id: "d7", name: "Yusuf Demir", depot: "east" },
  { id: "d8", name: "Clara Wendt", depot: "east" },
] as const;

const CUSTOMERS = [
  "Holloway Garden Supplies",
  "Marlow & Finch Books",
  "Oakridge Pharmacy",
  "Brixley Cycles",
  "Tamsin's Bakery",
  "Northfold Office",
  "Pellham Hardware",
  "Greywick Studio",
  "Juniper Lane Florist",
  "Ashcombe Dental",
] as const;

interface Stop {
  id: string;
  shipment: string;
  customer: string;
  /** The window the customer was promised, `[from, to]`. */
  window: readonly [number, number];
  /** When the tour plans to be there. */
  arrival: number;
}

interface Tour {
  id: string;
  vehicle: string;
  driver: string;
  depot: string;
  /** Leaves the depot, loaded; back at the depot. */
  from: number;
  to: number;
  /** Minutes of loading before `from`. */
  loading: number;
  km: number;
  stops: readonly Stop[];
}

/** One tour per vehicle. Stops every half hour or so, each with a two-hour
    window; every seventh arrival misses its window - the late ones a
    dispatcher looks for. */
function tour(index: number): Tour {
  const r = random(700 + index);
  const vehicle = VEHICLES[index]!;
  const truck = vehicle.type === "truck";
  const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
  const count = truck ? 5 : 8 + Math.floor(r() * 4);
  const stops: Stop[] = [];
  let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
  for (let i = 0; i < count; i++) {
    const n = index * 20 + i;
    const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
    /* A late stop's window closed half an hour or more before the arrival. */
    const late = i >= 2 && n % 7 === 3;
    const halfHour = 30 * MINUTE;
    const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
    stops.push({
      id: `s-${index + 1}-${i + 1}`,
      shipment: `FP-${(1_004_210 + n * 13).toString()}`,
      customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
      window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
      arrival,
    });
    arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
  }
  return {
    id: `T-${String(index + 1).padStart(2, "0")}`,
    vehicle: vehicle.id,
    driver: DRIVERS[index]!.id,
    depot: vehicle.depot,
    from,
    to: arrival + 30 * MINUTE,
    loading: truck ? 45 : 30,
    km: Math.round((truck ? 140 : 70) + r() * 40),
    stops,
  };
}

const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));

interface Shipment {
  id: string;
  customer: string;
  /** In kg. */
  weight: number;
  status: "delivered" | "out for delivery" | "failed attempt";
  window: readonly [number, number];
  tour: string;
}

/** One shipment per stop. Before now, delivered - or a failed attempt, where
    nobody was in; after now, out for delivery. */
const SHIPMENTS: readonly Shipment[] = TOURS.flatMap((one) =>
  one.stops.map((stop) => {
    const n = Number(stop.shipment.slice(3));
    return {
      id: stop.shipment,
      customer: stop.customer,
      weight: Math.round((VEHICLES.find((v) => v.id === one.vehicle)!.type === "truck" ? 180 : 2) + ((n * 7919) % 230) / 10),
      status: stop.arrival > NOW ? "out for delivery" : n % 11 === 0 ? "failed attempt" : "delivered",
      window: stop.window,
      tour: one.id,
    } satisfies Shipment;
  }),
);

/* Defined once, outside the component. The object is the key from here on:
   the id is only its name in the view. */
const tours = rowFilter({
  id: "tours",
  label: "Tours",
  matches: (shipment: Shipment, chosen: readonly string[]) => chosen.includes(shipment.tour),
});

const TOUR_OPTIONS = [...new Set(SHIPMENTS.map((s) => s.tour))].map((tour) => ({ value: tour, label: tour }));

export default function FieldNoColumnShows() {
  const t = useTable(SHIPMENTS, { rowKey: (s) => s.id, rowFilters: [tours] });
  const { Table, Column } = t;

  return (
    <Table ariaLabel="Shipments by tour">
      <Toolbar>
        <Search placeholder="Shipment or customer" />
        <MultiSelect
          aria-label="Tours"
          placeholder="All tours"
          options={TOUR_OPTIONS}
          /* Typed as the filter's condition - no cast. */
          value={t.conditionOf(tours) ?? []}
          /* The whole new choice, every time; an empty one lifts the condition. */
          onChange={(next) => t.setFilter(tours, next.length > 0 ? next : null)}
        />
      </Toolbar>
      <Column value="id" label="Shipment" rowHeader />
      <Column value="customer" label="Customer" />
      <Column value="weight" label="Weight (kg)" />
      <Pagination />
    </Table>
  );
}

Rows from a request

Hand the table the rows a query answers, as they come - with a fallback that is the same array on every render and loading while nothing has arrived. The filters stay the table's.

import { useCallback, useEffect, useState } from "react";
import { Button, MultiSelect } from "@umriss-ui/core";
import { Pagination, Toolbar, rowFilter, useTable } from "@umriss-ui/table";

/* Data from the logistics world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(10, 30);

interface Vehicle {
  id: string;
  plate: string;
  type: "van" | "e-van" | "truck";
  /** Payload, in kg. */
  capacity: number;
  depot: string;
}

const VEHICLES: readonly Vehicle[] = [
  { id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
  { id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
  { id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
  { id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
  { id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
  { id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
  { id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
  { id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];

const DRIVERS = [
  { id: "d1", name: "Martin Hale", depot: "north" },
  { id: "d2", name: "Nadia Petrova", depot: "north" },
  { id: "d3", name: "Owen Carter", depot: "north" },
  { id: "d4", name: "Lucia Romero", depot: "river" },
  { id: "d5", name: "Ben Adeyemi", depot: "river" },
  { id: "d6", name: "Hanna Berg", depot: "east" },
  { id: "d7", name: "Yusuf Demir", depot: "east" },
  { id: "d8", name: "Clara Wendt", depot: "east" },
] as const;

const CUSTOMERS = [
  "Holloway Garden Supplies",
  "Marlow & Finch Books",
  "Oakridge Pharmacy",
  "Brixley Cycles",
  "Tamsin's Bakery",
  "Northfold Office",
  "Pellham Hardware",
  "Greywick Studio",
  "Juniper Lane Florist",
  "Ashcombe Dental",
] as const;

interface Stop {
  id: string;
  shipment: string;
  customer: string;
  /** The window the customer was promised, `[from, to]`. */
  window: readonly [number, number];
  /** When the tour plans to be there. */
  arrival: number;
}

interface Tour {
  id: string;
  vehicle: string;
  driver: string;
  depot: string;
  /** Leaves the depot, loaded; back at the depot. */
  from: number;
  to: number;
  /** Minutes of loading before `from`. */
  loading: number;
  km: number;
  stops: readonly Stop[];
}

/** One tour per vehicle. Stops every half hour or so, each with a two-hour
    window; every seventh arrival misses its window - the late ones a
    dispatcher looks for. */
function tour(index: number): Tour {
  const r = random(700 + index);
  const vehicle = VEHICLES[index]!;
  const truck = vehicle.type === "truck";
  const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
  const count = truck ? 5 : 8 + Math.floor(r() * 4);
  const stops: Stop[] = [];
  let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
  for (let i = 0; i < count; i++) {
    const n = index * 20 + i;
    const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
    /* A late stop's window closed half an hour or more before the arrival. */
    const late = i >= 2 && n % 7 === 3;
    const halfHour = 30 * MINUTE;
    const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
    stops.push({
      id: `s-${index + 1}-${i + 1}`,
      shipment: `FP-${(1_004_210 + n * 13).toString()}`,
      customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
      window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
      arrival,
    });
    arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
  }
  return {
    id: `T-${String(index + 1).padStart(2, "0")}`,
    vehicle: vehicle.id,
    driver: DRIVERS[index]!.id,
    depot: vehicle.depot,
    from,
    to: arrival + 30 * MINUTE,
    loading: truck ? 45 : 30,
    km: Math.round((truck ? 140 : 70) + r() * 40),
    stops,
  };
}

const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));

interface Shipment {
  id: string;
  customer: string;
  /** In kg. */
  weight: number;
  status: "delivered" | "out for delivery" | "failed attempt";
  window: readonly [number, number];
  tour: string;
}

/** One shipment per stop. Before now, delivered - or a failed attempt, where
    nobody was in; after now, out for delivery. */
const SHIPMENTS: readonly Shipment[] = TOURS.flatMap((one) =>
  one.stops.map((stop) => {
    const n = Number(stop.shipment.slice(3));
    return {
      id: stop.shipment,
      customer: stop.customer,
      weight: Math.round((VEHICLES.find((v) => v.id === one.vehicle)!.type === "truck" ? 180 : 2) + ((n * 7919) % 230) / 10),
      status: stop.arrival > NOW ? "out for delivery" : n % 11 === 0 ? "failed attempt" : "delivered",
      window: stop.window,
      tour: one.id,
    } satisfies Shipment;
  }),
);

/* Stands in for any query hook: `data` stays the same array until an answer
   brings new rows, `isPending` holds until the first one. The first answer
   comes at once, so that the example stands still; "Reload" takes 800 ms. */
function useShipmentsQuery() {
  const [data, setData] = useState<readonly Shipment[] | undefined>(undefined);
  const [fetching, setFetching] = useState(0);
  useEffect(() => {
    const timer = setTimeout(() => setData([...SHIPMENTS]), fetching === 0 ? 0 : 800);
    return () => clearTimeout(timer);
  }, [fetching]);
  const refetch = useCallback(() => {
    setData(undefined);
    setFetching((n) => n + 1);
  }, []);
  return { data, isPending: data === undefined, refetch };
}

type Status = Shipment["status"];

const STATUSES: { value: Status; label: string }[] = [
  { value: "out for delivery", label: "Out for delivery" },
  { value: "failed attempt", label: "Failed attempt" },
  { value: "delivered", label: "Delivered" },
];

const statuses = rowFilter({
  id: "statuses",
  label: "Status",
  matches: (shipment: Shipment, chosen: readonly Status[]) => chosen.includes(shipment.status),
});

/* Outside the component: `data ?? []` would be a new array on every render,
   and the table would calculate everything anew each time while it waits. */
const NO_SHIPMENTS: readonly Shipment[] = [];

export default function RowsFromARequest() {
  const { data, isPending, refetch } = useShipmentsQuery();

  /* The answer goes in as it is - not copied into a state of one's own, which
     would lag a render behind and could go stale. Rows derived from it (mapped,
     joined) belong in a `useMemo` over `data`. The condition survives a reload:
     the table holds it, not the rows. */
  const t = useTable(data ?? NO_SHIPMENTS, { rowKey: (s) => s.id, rowFilters: [statuses] });
  const { Table, Column } = t;

  return (
    <Table ariaLabel="Shipments from a request" loading={isPending}>
      <Toolbar>
        <MultiSelect<Status>
          aria-label="Status"
          placeholder="Every status"
          options={STATUSES}
          value={t.conditionOf(statuses) ?? []}
          onChange={(next) => t.setFilter(statuses, next.length > 0 ? next : null)}
        />
        <Button variant="ghost" onClick={refetch}>
          Reload
        </Button>
      </Toolbar>
      <Column value="id" label="Shipment" rowHeader />
      <Column value="customer" label="Customer" />
      <Column value="status" label="Status" />
      <Pagination />
    </Table>
  );
}

Ask a question over several fields

"Overdue" is a window that has closed on a shipment not yet delivered - two fields and a moment. The condition carries what varies; describe puts it in the toolbar, where its cross lifts it.

import { Checkbox, Stack, Switch } from "@umriss-ui/core";
import { Pagination, Toolbar, rowFilter, useTable } from "@umriss-ui/table";

/* Data from the logistics world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(10, 30);

interface Vehicle {
  id: string;
  plate: string;
  type: "van" | "e-van" | "truck";
  /** Payload, in kg. */
  capacity: number;
  depot: string;
}

const VEHICLES: readonly Vehicle[] = [
  { id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
  { id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
  { id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
  { id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
  { id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
  { id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
  { id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
  { id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];

const DRIVERS = [
  { id: "d1", name: "Martin Hale", depot: "north" },
  { id: "d2", name: "Nadia Petrova", depot: "north" },
  { id: "d3", name: "Owen Carter", depot: "north" },
  { id: "d4", name: "Lucia Romero", depot: "river" },
  { id: "d5", name: "Ben Adeyemi", depot: "river" },
  { id: "d6", name: "Hanna Berg", depot: "east" },
  { id: "d7", name: "Yusuf Demir", depot: "east" },
  { id: "d8", name: "Clara Wendt", depot: "east" },
] as const;

const CUSTOMERS = [
  "Holloway Garden Supplies",
  "Marlow & Finch Books",
  "Oakridge Pharmacy",
  "Brixley Cycles",
  "Tamsin's Bakery",
  "Northfold Office",
  "Pellham Hardware",
  "Greywick Studio",
  "Juniper Lane Florist",
  "Ashcombe Dental",
] as const;

interface Stop {
  id: string;
  shipment: string;
  customer: string;
  /** The window the customer was promised, `[from, to]`. */
  window: readonly [number, number];
  /** When the tour plans to be there. */
  arrival: number;
}

interface Tour {
  id: string;
  vehicle: string;
  driver: string;
  depot: string;
  /** Leaves the depot, loaded; back at the depot. */
  from: number;
  to: number;
  /** Minutes of loading before `from`. */
  loading: number;
  km: number;
  stops: readonly Stop[];
}

/** One tour per vehicle. Stops every half hour or so, each with a two-hour
    window; every seventh arrival misses its window - the late ones a
    dispatcher looks for. */
function tour(index: number): Tour {
  const r = random(700 + index);
  const vehicle = VEHICLES[index]!;
  const truck = vehicle.type === "truck";
  const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
  const count = truck ? 5 : 8 + Math.floor(r() * 4);
  const stops: Stop[] = [];
  let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
  for (let i = 0; i < count; i++) {
    const n = index * 20 + i;
    const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
    /* A late stop's window closed half an hour or more before the arrival. */
    const late = i >= 2 && n % 7 === 3;
    const halfHour = 30 * MINUTE;
    const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
    stops.push({
      id: `s-${index + 1}-${i + 1}`,
      shipment: `FP-${(1_004_210 + n * 13).toString()}`,
      customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
      window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
      arrival,
    });
    arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
  }
  return {
    id: `T-${String(index + 1).padStart(2, "0")}`,
    vehicle: vehicle.id,
    driver: DRIVERS[index]!.id,
    depot: vehicle.depot,
    from,
    to: arrival + 30 * MINUTE,
    loading: truck ? 45 : 30,
    km: Math.round((truck ? 140 : 70) + r() * 40),
    stops,
  };
}

const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));

interface Shipment {
  id: string;
  customer: string;
  /** In kg. */
  weight: number;
  status: "delivered" | "out for delivery" | "failed attempt";
  window: readonly [number, number];
  tour: string;
}

/** One shipment per stop. Before now, delivered - or a failed attempt, where
    nobody was in; after now, out for delivery. */
const SHIPMENTS: readonly Shipment[] = TOURS.flatMap((one) =>
  one.stops.map((stop) => {
    const n = Number(stop.shipment.slice(3));
    return {
      id: stop.shipment,
      customer: stop.customer,
      weight: Math.round((VEHICLES.find((v) => v.id === one.vehicle)!.type === "truck" ? 180 : 2) + ((n * 7919) % 230) / 10),
      status: stop.arrival > NOW ? "out for delivery" : n % 11 === 0 ? "failed attempt" : "delivered",
      window: stop.window,
      tour: one.id,
    } satisfies Shipment;
  }),
);

interface Overdue {
  /** The moment the windows are read against. */
  at: number;
  /** Count a failed attempt as overdue too. */
  failed: boolean;
}

/* What varies - the moment, the choice about failed attempts - stands in the
   condition, never in a closure over the component's state: the table sees a
   new condition, it cannot see a closure change. */
const overdue = rowFilter({
  id: "overdue",
  label: "Overdue",
  matches: (shipment: Shipment, o: Overdue) =>
    shipment.window[1] < o.at &&
    (shipment.status === "out for delivery" || (o.failed && shipment.status === "failed attempt")),
  describe: (o) => (o.failed ? "with failed attempts" : "not delivered"),
});

export default function QuestionOverSeveralFields() {
  const t = useTable(SHIPMENTS, {
    rowKey: (s) => s.id,
    rowFilters: [overdue],
    pageSize: 5,
    /* A view can start with it set, like any condition. */
    initialView: { conditions: { overdue: { at: NOW, failed: false } } },
  });
  const { Table, Column } = t;
  const condition = t.conditionOf(overdue);

  /* The demo reads its windows at a fixed "now"; an application takes Date.now(). */
  const set = (on: boolean, failed: boolean) => t.setFilter(overdue, on ? { at: NOW, failed } : null);

  return (
    <Stack gap={3}>
      <Stack direction="row" gap={4} wrap>
        <Switch label="Overdue only" checked={condition !== null} onChange={(e) => set(e.target.checked, condition?.failed ?? false)} />
        <Checkbox
          label="Count failed attempts"
          disabled={condition === null}
          checked={condition?.failed ?? false}
          onChange={(e) => set(true, e.target.checked)}
        />
      </Stack>

      <Table ariaLabel="Shipments and their windows">
        <Toolbar />
        <Column value="id" label="Shipment" rowHeader />
        <Column value="customer" label="Customer" />
        <Column value="status" label="Status" filter="list" />
        <Pagination />
      </Table>
    </Stack>
  );
}

Offer the values that occur

The Input of a filter of your own receives values, the column's values each once and in order, so it offers only thresholds some row meets.

import { RadioGroup } from "@umriss-ui/core";
import { columnFilter, useTable } from "@umriss-ui/table";

const atLeast = columnFilter<number, number>({
  matches: (hours, least) => hours >= least,
  Input: ({ condition, setCondition, values, column }) => (
    <RadioGroup
      aria-label={column.label}
      size="sm"
      options={values.map((hours) => ({ value: String(hours), label: `${hours} h and more` }))}
      value={condition === null ? null : String(condition)}
      onChange={(hours) => setCondition(Number(hours))}
    />
  ),
  describe: (least) => `${least} h and more`,
});

interface Person {
  id: string;
  name: string;
  role: string;
  capacity: number;
}

const PEOPLE: Person[] = [
  { id: "maya", name: "Maya Lindgren", role: "Product manager", capacity: 32 },
  { id: "arjun", name: "Arjun Mehta", role: "Developer", capacity: 40 },
  { id: "noah", name: "Noah Fischer", role: "Designer", capacity: 24 },
  { id: "kofi", name: "Kofi Mensah", role: "Developer", capacity: 32 },
  { id: "david", name: "David Kowalski", role: "QA engineer", capacity: 20 },
];

export default function OfferTheValuesThatOccur() {
  const { Table, Column } = useTable(PEOPLE, { rowKey: (p) => p.id });

  return (
    <Table ariaLabel="Team">
      <Column value="name" label="Person" rowHeader />
      <Column value="role" label="Role" />
      <Column value="capacity" label="Hours a week" filter={atLeast} />
    </Table>
  );
}

When to use something else

Keyboard

The keys of First table, Popover, Checkbox, Input, MultiSelect, Select and Button apply here.

API

ColumnFilter<W, B>

NameTypeDefaultDescription
matchesrequired(value: Present<W>, condition: B) => boolean—Whether a value that is present satisfies the condition. Never called for an absent value: that satisfies none.

Shown in: Write the smallest filter of your own, Offer conditions of your own, Offer the values that occur

InputrequiredComponentType<FilterInputProps<W, B>>—What stands in the filter's panel. The footer with "reset" and "done" the table puts underneath it.

Shown in: Write the smallest filter of your own, Offer conditions of your own, Offer the values that occur

describerequired(condition: B) => string—The text of the condition in the table toolbar, behind the column's name.

Shown in: Write the smallest filter of your own, Offer conditions of your own, Offer the values that occur

FilterInputProps<W, B>

NameTypeDefaultDescription
conditionrequiredB | null—The condition that holds right now; null as long as the filter requires nothing.

Shown in: Write the smallest filter of your own, Offer conditions of your own, Offer the values that occur

setConditionrequired(condition: B | null) => void—Sets the condition; null lifts it.

Shown in: Write the smallest filter of your own, Offer conditions of your own, Offer the values that occur

valuesrequiredreadonly Present<W>[]—The values that occur in the rows the pre-filter admits – each one once, ordered, without the absent one.

Shown in: Offer the values that occur

columnrequired{ id: string; label: string }—The column whose filter this is.

Shown in: Offer conditions of your own, Offer the values that occur

RowFilter<Z, B>

NameTypeDefaultDescription
idrequiredstring—Its name in t.view.conditions and in what server mode reports - the one place the filter is spelled. Not the id of a column of the same table.

Shown in: Filter by a field no column shows, Rows from a request, Ask a question over several fields and 3 more

labelrequiredstring—What it is called: before the text of its chip, and in the chip's accessible names.

Shown in: Filter by a field no column shows, Rows from a request, Ask a question over several fields and 3 more

matchesrequired(row: Z, condition: B) => boolean—Whether the row satisfies the condition. Any question over the whole row; whatever varies belongs in the condition - the table cannot see a change in a closure.

Shown in: Filter by a field no column shows, Rows from a request, Ask a question over several fields and 3 more

describe(condition: B) => string—The text of the condition in the table toolbar, behind the label. Without it the condition has no chip: the application's control shows it.

Shown in: Ask a question over several fields, A dispatcher's bar (Toolbar controls)

Types on this page

Present<W>

The value without the absence - that is what children gets.

type Present<W> = Exclude<W, Absent>;

Absent

A value that is not there: null or undefined.

type Absent = null | undefined;

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/table

Getting started

Columns

Finding rows

Grouping

Rows

Around the table

Many rows

Editing

Limits and alarms

API index