# @umriss-ui/table 0.40.0 > React data table for data-dense applications - sorting, filtering, grouping, aggregates, virtualisation. Declared the way it reads: columns as JSX, typed against their rows. Install with `npm install @umriss-ui/table @umriss-ui/core`. This text is generated from the package's demo (https://romanhaendler.github.io/umriss-ui/table/): every page with its import line, its examples - the source exactly as it runs, with the package name where the demo imports its own source - its props tables generated from the code, and what it deliberately does not do. The pages index stands in https://romanhaendler.github.io/umriss-ui/table/llms.txt. ## Scenarios Composed, realistic screens built from the package. A numbered mark on the screen is an element with `data-callout`. ### Work through the alerts The on-call engineer at Quillmere opens the alert list, takes the worst first and acknowledges what they have seen. 1. Who carries the pager right now, and how many alerts still wait for someone to see them. 2. The alerts stand worst first: by priority, then what nobody has acknowledged, then the newest. A resolved alert nobody acknowledged stays until someone does. 3. Tick rows and acknowledge them in one go. Built from: First table, Stat, Badge, Button. ```tsx import { useState } from "react"; import { Badge, Button, Grid, Stack, Stat, Text, useFormats } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; /* Data from the operations world, written out here so the example runs on its own. */ const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime(); /** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */ const NOW = at(17, 10, 30); interface Engineer { id: string; name: string; team: string; } const ENGINEERS: readonly Engineer[] = [ { id: "priya", name: "Priya Raman", team: "Payments" }, { id: "jonas", name: "Jonas Keller", team: "Payments" }, { id: "ada", name: "Ada Mwangi", team: "Identity" }, { id: "tomasz", name: "Tomasz Nowak", team: "Discovery" }, { id: "leila", name: "Leila Haddad", team: "Discovery" }, { id: "sam", name: "Sam Okafor", team: "Messaging" }, { id: "ines", name: "Ines Duarte", team: "Integrations" }, { id: "felix", name: "Felix Brandt", team: "Insights" }, ]; interface OnCall { id: string; engineer: string; rotation: "primary" | "secondary"; from: number; to: number; } /** Monday 16 to Monday 23 March, handed over every morning at 09:00: the secondary of one day is the primary of the next. */ const ONCALL: readonly OnCall[] = Array.from({ length: 7 }, (_, day) => [ { rotation: "primary" as const, engineer: ENGINEERS[day % ENGINEERS.length]!.id }, { rotation: "secondary" as const, engineer: ENGINEERS[(day + 1) % ENGINEERS.length]!.id }, ].map(({ rotation, engineer }) => ({ id: `${rotation}-${16 + day}`, engineer, rotation, from: at(16 + day, 9), to: at(17 + day, 9), }))).flat(); interface Incident { id: string; title: string; service: string; severity: "SEV1" | "SEV2" | "SEV3"; opened: number; acknowledged?: number; resolved?: number; /** An engineer's id. */ assignee: string; } const INCIDENTS: readonly Incident[] = [ { id: "INC-1048", title: "Checkout slow, card payments time out", service: "checkout", severity: "SEV1", opened: at(17, 9, 42), acknowledged: at(17, 9, 46), assignee: "jonas" }, { id: "INC-1047", title: "Webhook deliveries delayed", service: "webhooks", severity: "SEV3", opened: at(17, 7, 15), acknowledged: at(17, 8, 2), assignee: "ines" }, { id: "INC-1046", title: "Thumbnails missing for new uploads", service: "images", severity: "SEV2", opened: at(16, 22, 5), acknowledged: at(16, 22, 11), resolved: at(17, 0, 40), assignee: "tomasz" }, { id: "INC-1045", title: "Sign-in codes arrive late", service: "sign-in", severity: "SEV2", opened: at(16, 14, 20), acknowledged: at(16, 14, 24), resolved: at(16, 15, 5), assignee: "ada" }, { id: "INC-1044", title: "Monthly report export fails", service: "reports", severity: "SEV3", opened: at(15, 10, 0), acknowledged: at(16, 9, 12), resolved: at(16, 11, 30), assignee: "felix" }, { id: "INC-1043", title: "Search returns no results for some regions", service: "search", severity: "SEV1", opened: at(14, 18, 30), acknowledged: at(14, 18, 33), resolved: at(14, 19, 55), assignee: "leila" }, { id: "INC-1042", title: "Duplicate reminder e-mails", service: "notifications", severity: "SEV3", opened: at(13, 8, 45), acknowledged: at(13, 9, 30), resolved: at(13, 13, 0), assignee: "sam" }, { id: "INC-1041", title: "Invoices generated twice", service: "billing", severity: "SEV2", opened: at(12, 16, 10), acknowledged: at(12, 16, 18), resolved: at(12, 18, 40), assignee: "priya" }, ]; type Priority = "high" | "medium" | "low"; interface Alert { id: string; raised: number; priority: Priority; message: string; state: "Active" | "Resolved"; acknowledgedAt?: number; } const TONE = { high: "danger", medium: "warning", low: "neutral" } as const; const RANK = { high: 0, medium: 1, low: 2 } as const; /** Worst first: by priority, then what nobody has acknowledged, then the newest. */ const WORST_FIRST = [ { column: "priority", direction: "asc" }, { column: "acknowledged", direction: "asc" }, { column: "raised", direction: "desc" }, ] as const; /** The alerts as they stand at 10:30. */ const ALERTS: readonly Alert[] = [ { id: "a-1", raised: at(17, 9, 41), priority: "high", message: "Checkout · p95 latency above 300 ms", state: "Active", acknowledgedAt: at(17, 9, 46) }, { id: "a-2", raised: at(17, 9, 43), priority: "high", message: "Checkout · error rate above 2\u00a0%", state: "Resolved" }, { id: "a-3", raised: at(17, 7, 12), priority: "medium", message: "Webhooks · queue older than 5 min", state: "Active", acknowledgedAt: at(17, 8, 2) }, { id: "a-4", raised: at(17, 10, 24), priority: "medium", message: "Search · p95 latency above 250 ms", state: "Active" }, { id: "a-5", raised: at(17, 8, 5), priority: "medium", message: "Search · p95 latency above 250 ms", state: "Resolved", acknowledgedAt: at(17, 8, 6) }, ]; const onCall = (rotation: "primary" | "secondary") => { const turn = ONCALL.find((one) => one.rotation === rotation && one.from <= NOW && NOW < one.to); return ENGINEERS.find((e) => e.id === turn?.engineer)?.name ?? "nobody"; }; export default function WorkThroughTheAlerts() { const formats = useFormats(); const [alerts, setAlerts] = useState(ALERTS); const t = useTable(alerts, { rowKey: (a) => a.id, defaultSort: WORST_FIRST }); const { Table, Column } = t; const waiting = alerts.filter((a) => a.acknowledgedAt === undefined).length; const time = (instant: number) => formats.time(new Date(instant), false); return (
i.resolved === undefined).length} /> On call {onCall("primary")}, primary {onCall("secondary")}, secondary
{(raised) => time(raised)} RANK[priority]}> {(priority) => {priority}} a.acknowledgedAt ?? 0}> {(_, a) => (a.acknowledgedAt === undefined ? "No" : time(a.acknowledgedAt))}
); } ``` ### Find a late shipment A dispatcher at Ferrow Parcel answers a customer's call: finds the shipment, sees whether it missed its window, and tidies the day's list. 1. One click filters to the late shipments from outside the table: the same condition the Punctuality filter sets, shown in the toolbar and removable there. 2. Search by shipment number or customer; the toolbar names how many of the day's shipments are found, and the footer sums their weight and names the longest delay among them. 3. The column menu hides and orders columns, and the export writes what is shown, filtered, as CSV. 4. Notify a customer from the row, or tick shipments and archive them with one confirmation; what happened stands here. Built from: First table, Filter, Search, Aggregate, RowDetail, RowActions, Pagination, Badge, ConfirmDialog. ```tsx import { useLayoutEffect, useRef, useState, type RefObject } from "react"; import { Badge, Button, ConfirmDialog, Grid, Stack, Text, useFormats } from "@umriss-ui/core"; import { ColumnMenu, Export, Pagination, Search, Toolbar, 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); const DEPOTS = [ { id: "north", name: "North depot" }, { id: "river", name: "Riverside depot" }, { id: "east", name: "East Gate depot" }, ] as const; 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 Row { id: string; customer: string; tour: string; depot: string; weight: number; status: "Delivered" | "Out for delivery" | "Failed attempt"; window: readonly [number, number]; arrival: number; /** Minutes after the window closed; `null` when inside it. */ delay: number | null; driver: string; plate: string; } const STATUS = { delivered: "Delivered", "out for delivery": "Out for delivery", "failed attempt": "Failed attempt" } as const; const TONE = { Delivered: "success", "Out for delivery": "accent", "Failed attempt": "danger" } as const; /* The shipment, joined with its stop and its tour. */ const START: Row[] = SHIPMENTS.map((shipment) => { const tour = TOURS.find((t) => t.id === shipment.tour)!; const stop = tour.stops.find((s) => s.shipment === shipment.id)!; const late = stop.arrival - stop.window[1]; return { id: shipment.id, customer: shipment.customer, tour: tour.id, depot: DEPOTS.find((d) => d.id === tour.depot)!.name, weight: shipment.weight, status: STATUS[shipment.status], window: stop.window, arrival: stop.arrival, delay: late > 0 ? late / 60_000 : null, driver: DRIVERS.find((d) => d.id === tour.driver)!.name, plate: VEHICLES.find((v) => v.id === tour.vehicle)!.plate, }; }); function Fact({ name, value }: { name: string; value: string }) { return ( {name} {value} ); } function Detail({ row }: { row: Row }) { const formats = useFormats(); return ( ); } /* A pinned column needs room beside it: on a phone it covers half the table. So the column sticks only where the place the screen stands in is at least 640 px wide - measured on the place itself, not on the window, which knows nothing of a sidebar or a split view. Narrower, the table scrolls whole. */ function useWide(): [RefObject, boolean] { const ref = useRef(null); const [wide, setWide] = useState(true); useLayoutEffect(() => { const element = ref.current; if (!element) return; const observer = new ResizeObserver(() => setWide(element.getBoundingClientRect().width >= 640)); observer.observe(element); return () => observer.disconnect(); }, []); return [ref, wide]; } export default function FindALateShipment() { const formats = useFormats(); const [rows, setRows] = useState(START); const [toArchive, setToArchive] = useState([]); const [message, setMessage] = useState("No action yet."); const [place, wide] = useWide(); const t = useTable(rows, { rowKey: (r) => r.id, pageSize: 10, defaultSort: { column: "id", direction: "asc" }, }); const { Table, Column, RowDetail, RowActions, Action } = t; const late = rows.filter((r) => r.delay !== null).length; const archive = () => { setRows((previous) => previous.filter((r) => !toArchive.includes(r))); t.selection.clear(); setMessage(`${toArchive.map((r) => r.id).join(", ")} archived`); setToArchive([]); }; return ( {(status) => {status}} r.window[0]}> {(_, r) => `${formats.time(new Date(r.window[0]), false)}–${formats.time(new Date(r.window[1]), false)}`} (r.delay === null ? "On time" : "Late")} filter="list"> {(word) => (word === "Late" ? Late : word)} {(r) => } setMessage(`${r.id}: ${r.customer} notified`)}>Notify setToArchive(list)}> Archive
{message} 0} onClose={() => setToArchive([])} onConfirm={archive} title={toArchive.length === 1 ? "Archive one shipment?" : `Archive ${toArchive.length} shipments?`} description="Archived shipments disappear from the day's list." confirmLabel="Archive" tone="danger" />
); } ``` ### Review cost centres against budget The controller at Carrow & Lisle reviews the first quarter before the month-end call: which cost centres will land over budget, and by how much. 1. The quarter in three tiles; the variance is read against the same limits as the column below. 2. Search a cost centre or its owner; the export writes the report as it stands, with its groups. 3. Cost centres grouped by area. Each area's line sums its budget, actual, outlook and variance, and carries the worst verdict of its centres. 4. The outlook is January and February as booked plus March as forecast. Marketing's campaign runs over the alarm limit; IT's delayed licences run under. Built from: Grouping, Aggregate, VerdictColumn, Toolbar, Export, Stat. ```tsx import { Grid, Stack, Stat } from "@umriss-ui/core"; import type { LimitSet } from "@umriss-ui/core"; import { Export, Search, Toolbar, useTable } from "@umriss-ui/table"; /* Data from the controlling 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; }; } interface CostCentre { id: string; name: string; owner: string; /** The budget of one month. */ monthlyBudget: number; } const COST_CENTRES: readonly CostCentre[] = [ { id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 }, { id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 }, { id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 }, { id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 }, { id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 }, { id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 }, { id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 }, { id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 }, { id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 }, ]; /** The year's months, as `"2026-01"` … `"2026-12"`. */ const MONTHS: readonly string[] = Array.from({ length: 12 }, (_, i) => `2026-${String(i + 1).padStart(2, "0")}`); /** The months whose books are closed: they have an actual. */ const CLOSED_MONTHS = 2; interface LedgerRow { costCentre: string; month: string; budget: number; /** `null` while the month is open. */ actual: number | null; forecast: number; } /* Where a cost centre lands against its budget, as a factor: Marketing's spring campaign overspends, IT's delayed licences underspend. */ const TENDENCY: Readonly> = { "CC-1200": 1.14, "CC-4300": 0.88 }; /** Budget, actual and forecast of every cost centre in every month. */ const LEDGER: readonly LedgerRow[] = COST_CENTRES.flatMap((centre, c) => { const r = random(310 + c); const tendency = TENDENCY[centre.id] ?? 1; return MONTHS.map((month, m) => { /* December pays the bonuses, August is the quiet month. */ const season = m === 11 ? 1.12 : m === 7 ? 0.9 : 1; const budget = Math.round(centre.monthlyBudget * season); const forecast = Math.round((budget * (tendency + (r() - 0.5) * 0.06)) / 100) * 100; const actual = m < CLOSED_MONTHS ? Math.round(forecast * (1 + (r() - 0.5) * 0.08)) : null; return { costCentre: centre.id, month, budget, actual, forecast }; }); }); /* Over budget by 3 % is worth a question, by 8 % an explanation; 10 % under means money planned and not spent. */ const VARIANCE: LimitSet = { target: 0, limits: [ { value: 3, side: "upper", severity: "warning" }, { value: 8, side: "upper", severity: "alarm" }, { value: -10, side: "lower", severity: "warning" }, ], }; const AREAS: Record = { "1": "Commercial", "2": "Product", "3": "Service", "4": "Administration" }; const QUARTER = MONTHS.slice(0, 3); interface Row { id: string; area: string; name: string; owner: string; budget: number; /** Booked, in the closed months. */ actual: number; /** Booked where closed, forecast where open. */ outlook: number; } const ROWS: Row[] = COST_CENTRES.map((centre) => { const months = LEDGER.filter((l) => l.costCentre === centre.id && QUARTER.includes(l.month)); const sum = (pick: (l: (typeof months)[number]) => number) => months.reduce((s, l) => s + pick(l), 0); return { id: centre.id, area: AREAS[centre.id[3]!]!, name: centre.name, owner: centre.owner, budget: sum((l) => l.budget), actual: sum((l) => l.actual ?? 0), outlook: sum((l) => l.actual ?? l.forecast), }; }); const total = (pick: (r: Row) => number) => ROWS.reduce((s, r) => s + pick(r), 0); const EURO = { decimals: 0 } as const; export default function ReviewCostCentres() { const t = useTable(ROWS, { rowKey: (r) => r.id, defaultGrouping: "area", defaultSort: { column: "id", direction: "asc" }, }); const { Table, Column, VerdictColumn } = t; const budget = total((r) => r.budget); const outlook = total((r) => r.outlook); return (
r.actual)} unit="€" decimals={0} />
{(name, r) => (r.id === "CC-1200" ? {name} : name)} r.outlook - r.budget} format={EURO} aggregate="sum" share={false} /> ((r.outlook - r.budget) / r.budget) * 100} limits={VARIANCE} format={{ decimals: 1 }} aggregate="worst" />
); } ``` ### Plan the team's capacity Tidewell's delivery lead fills in the hours each person is booked for the next three weeks, and sees at once who is booked beyond what they have. 1. How many hours both teams still have free this week, and how many people are booked beyond their capacity. 2. The table is a grid: one tab stop, arrows walk the cells. Enter, F2 or typing edits a week or a capacity; Enter keeps it, Escape drops it. 3. Leave takes days out of the week: Arjun's holiday leaves him 16 hours, and the free column says by how much he is overbooked. 4. The table reports each edit and changes nothing itself; the application writes it into its rows, and the sums follow. Built from: First table, Column, Grouping, Aggregate, Stat, Badge. ```tsx import { useLayoutEffect, useRef, useState, type RefObject } from "react"; import { Badge, Grid, Stack, Stat, Text } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; /* Data from the planning world, written out here so the example runs on its own. */ const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime(); interface Person { id: string; name: string; role: "Developer" | "Designer" | "Product manager" | "QA engineer"; team: "Web" | "Apps"; /** Hours a week they can be planned for. */ capacity: number; } const PEOPLE: readonly Person[] = [ { id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 }, { id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 }, { id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 }, { id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 }, { id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 }, { id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 }, { id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 }, { id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 }, { id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 }, { id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 }, ]; interface Leave { id: string; person: string; from: number; to: number; reason: "Holiday" | "Sick" | "Training"; } /** When people are away: blocked time on their lane. */ const LEAVE: readonly Leave[] = [ { id: "l-1", person: "noah", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Sick" }, { id: "l-2", person: "arjun", from: at(18, 0, 0), to: at(21, 0, 0), reason: "Holiday" }, { id: "l-3", person: "kofi", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Training" }, { id: "l-4", person: "freya", from: at(16, 0, 0), to: at(18, 0, 0), reason: "Holiday" }, { id: "l-5", person: "david", from: at(9, 0, 0), to: at(14, 0, 0), reason: "Holiday" }, { id: "l-6", person: "maya", from: at(19, 13, 0), to: at(20, 17, 0), reason: "Training" }, ]; const WEEK = [16, 17, 18, 19, 20]; /** Working days of this week a person is away. */ const awayDays = (person: string) => WEEK.filter((day) => { const noon = new Date(2026, 2, day, 12).getTime(); return LEAVE.some((l) => l.person === person && l.from <= noon && noon < l.to); }).length; /** Hours booked for the weeks of 16, 23 and 30 March. */ const BOOKED: Record = { maya: [24, 28, 30], arjun: [32, 36, 38], chloe: [38, 40, 36], noah: [20, 22, 18], eva: [34, 36, 40], luis: [30, 32, 32], hana: [40, 38, 36], kofi: [28, 30, 24], freya: [20, 36, 38], david: [18, 20, 16], }; interface Row { id: string; name: string; role: string; team: string; /** Hours a week. */ capacity: number; away: number; week16: number; week23: number; week30: number; } const START: Row[] = PEOPLE.map((p) => { const [week16, week23, week30] = BOOKED[p.id]!; return { id: p.id, name: p.name, role: p.role, team: p.team, capacity: p.capacity, away: awayDays(p.id), week16, week23, week30 }; }); const free = (r: Row) => (r.capacity * (5 - r.away)) / 5 - r.week16; const hours = (value: number | null) => (value === null || value < 0 || value > 60 ? "Between 0 and 60 hours" : undefined); /* A pinned column needs room beside it: on a phone it covers half the table. So the column sticks only where the place the screen stands in is at least 640 px wide - measured on the place itself, not on the window, which knows nothing of a sidebar or a split view. Narrower, the table scrolls whole. */ function useWide(): [RefObject, boolean] { const ref = useRef(null); const [wide, setWide] = useState(true); useLayoutEffect(() => { const element = ref.current; if (!element) return; const observer = new ResizeObserver(() => setWide(element.getBoundingClientRect().width >= 640)); observer.observe(element); return () => observer.disconnect(); }, []); return [ref, wide]; } export default function PlanTheTeamCapacity() { const [rows, setRows] = useState(START); const [last, setLast] = useState("No change yet."); const [place, wide] = useWide(); const { Table, Column } = useTable(rows, { rowKey: (r) => r.id, defaultGrouping: "team" }); return (
s + Math.max(0, free(r)), 0)} unit="h" decimals={0} /> free(r) < 0).length} />
{ setRows((all) => all.map((r) => (r.id === rowKey ? { ...r, [columnId]: value } : r))); setLast(`${row.name}, ${columnId === "capacity" ? "capacity" : `week of ${columnId.slice(4)} March`}: ${String(value)} h`); }} > {(value, r) => { const cell = value < 0 ? {`${Math.round(-value)} over`} : Math.round(value); return r.id === "arjun" ? {cell} : cell; }}
{last}
); } ``` ## Getting started What an application sets up once, before its first table. ### Installation Two packages, no stylesheet to import, and one hook that turns an array of rows into a table. ```ts import { useTable } from "@umriss-ui/table"; ``` ```sh npm install @umriss-ui/table @umriss-ui/core ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/installation/ The command brings the core package along, a peer dependency: the table reads its provider, its formats and its wording ([ADR-0016](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0016-the-table-is-a-package-that-depends-on-ui.md)). React 19 or newer is a peer as well and stays the application's own. There is no stylesheet to import. The table loads its own, and the core package loads the tokens it reads; both lie in cascade layers, so the application's CSS wins. `@umriss-ui/table/styles.css` stays exported for setups that link stylesheets by hand. A table is read by default: a native table, where every control is one Tab away and a screen reader keeps its table keys. Grid mode makes it one Tab stop whose cells the arrow keys walk, and it is the mode for editing. Switch it on only where people work in the cells ([ADR-0034](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0034-the-table-becomes-a-grid-only-on-request.md)). #### Examples ##### A first table [`useTable`](#/api/useTable) binds the row kind once; its `Column` offers exactly the fields a person has, and `rowKey` names each row. ```tsx import { useTable } from "@umriss-ui/table"; interface Person { id: string; name: string; role: string; team: string; capacity: number; } const PEOPLE: Person[] = [ { id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 }, { id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 }, { id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 }, { id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 }, { id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 }, ]; export default function FirstTable() { const { Table, Column } = useTable(PEOPLE, { rowKey: (p) => p.id }); return (
); } ``` #### Keyboard The keys of [First table](#/first-table/keyboard-first-table) apply here. #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | ### First table Rows of records to scan, sort, search and select (also called a data table or data grid). Reach for it when people compare many records by the same fields; from a few rows to a million on a server. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/first-table/ [`useTable`](#/api/useTable) takes the rows and hands back the `Table` and the `Column` that belong to them, so a column can only name a field the row has. The parts that touch no row, such as `Toolbar` or `Search`, are ordinary imports. Every option of the hook and every prop of the table stands in the tables below; each feature has a page of its own in the sidebar. #### Examples ##### Show a list of services Pass the rows to [`useTable`](#/api/useTable) and declare a `Column` per field; `rowHeader` names the row, and numbers align right and sort by value. ```tsx import { useTable } from "@umriss-ui/table"; interface Service { id: string; name: string; team: string; tier: number; latencySlo: number; } const SERVICES: Service[] = [ { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300 }, { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200 }, { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250 }, { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800 }, ]; export default function FirstTable() { const { Table, Column } = useTable(SERVICES, { rowKey: (s) => s.id }); return (
); } ``` ##### Search and page from a header of your own `t.search` and `t.setSearch` bind a field of the application's own, `t.page` and `t.setPage` a pager in the card's header. The table pages only while a `Pagination` stands in it; both pagers move the same page. ```tsx import { Button, Card, CardBody, CardHeader, Input, Stack, Text } from "@umriss-ui/core"; import { Pagination, useTable } from "@umriss-ui/table"; import type { TableRef } from "@umriss-ui/table"; interface Service { id: string; name: string; team: string; tier: number; } const SERVICES: Service[] = [ { id: "checkout", name: "Checkout", team: "Payments", tier: 1 }, { id: "billing", name: "Billing", team: "Payments", tier: 1 }, { id: "refunds", name: "Refunds", team: "Payments", tier: 2 }, { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1 }, { id: "accounts", name: "Accounts", team: "Identity", tier: 2 }, { id: "search", name: "Search", team: "Discovery", tier: 1 }, { id: "recommendations", name: "Recommendations", team: "Discovery", tier: 3 }, { id: "catalogue", name: "Catalogue", team: "Discovery", tier: 2 }, { id: "notifications", name: "Notifications", team: "Messaging", tier: 2 }, { id: "webhooks", name: "Webhooks", team: "Messaging", tier: 3 }, { id: "image-service", name: "Image service", team: "Platform", tier: 2 }, { id: "reporting", name: "Reporting", team: "Platform", tier: 3 }, ]; /* Written once for every table of the application, so it takes the table as `of`, as the package's own parts do. It says which rows stand on the page, out of how many: `rowCount` is what the search leaves, `rows` all there are. A table that scrolls instead of paging has no page to step through. */ function RangePager({ of }: { of: TableRef }) { if (of.virtual || of.rowCount === 0) return null; const first = (of.page - 1) * of.pageSize + 1; const last = Math.min(of.page * of.pageSize, of.rowCount); return ( {first}–{last} of {of.rowCount} {of.rowCount < of.rows.length && ` (${of.rows.length} in all)`} ); } export default function SearchAndPageFromAHeader() { const t = useTable(SERVICES, { rowKey: (s) => s.id, pageSize: 5 }); const { Table, Column } = t; const all = t.pageSize >= t.rows.length; return ( t.setSearch(event.target.value)} /> } />
); } ``` ##### Sort, arrange and export from controls of your own `t.toggleSort`, `t.toggleColumn` and `t.setOrder` change what the headers and the column menu change; `t.sort`, `t.hidden` and `t.order` say how it stands. `t.asCsv()` is the text an export writes. ```tsx import { useState } from "react"; import { Button, Checkbox, Select, Stack } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; import type { TableRef } from "@umriss-ui/table"; interface Service { id: string; name: string; team: string; tier: number; latencySlo: number; } const SERVICES: Service[] = [ { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300 }, { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200 }, { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250 }, { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800 }, { id: "reporting", name: "Reporting", team: "Platform", tier: 3, latencySlo: 2000 }, ]; /** The columns the panel arranges, by id - the row header stays where it is. */ const COLUMNS = [ { id: "team", label: "Team" }, { id: "tier", label: "Tier" }, { id: "latencySlo", label: "Latency objective (ms)" }, ] as const; /* Written once for every table of the application. In server mode the table holds one page of the server's, and that is all `asCsv` can write - so the button says so instead of pretending to copy the whole list. */ function CopyAsCsv({ of }: { of: TableRef }) { const [copied, setCopied] = useState(false); return ( ); } export default function SortArrangeAndExport() { const t = useTable(SERVICES, { rowKey: (s) => s.id, defaultSort: { column: "tier", direction: "asc" } }); const { Table, Column } = t; /* Empty until somebody arranges: then the natural order. */ const order = t.order.length > 0 ? t.order.filter((id) => COLUMNS.some((c) => c.id === id)) : COLUMNS.map((c) => c.id); const sorted = t.sort[0]; const moveLeft = (id: string) => { const at = order.indexOf(id); t.setOrder(["name", ...order.slice(0, at - 1), id, order[at - 1]!, ...order.slice(at + 1)]); }; return ( {order.map((id, i) => { const label = COLUMNS.find((c) => c.id === id)!.label; return ( t.toggleColumn(id)} /> ); })}
); } ``` #### When to use something else - Measured values read against their limits → VerdictColumn - Three or four key figures side by side → `Stat` from @umriss-ui/core #### Keyboard | Key | Action | |---|---| | `Enter / Space` | On a sortable header: sorts by the column, ascending; pressed again, descending. | | `Escape` | Closes the tip that shows a cut value whole. | #### Accessibility A native `table`, named by `ariaLabel`: without it a screen reader announces a table with no name, so pass one, the more so where a page holds two. The `rowHeader` column's cells are row headers, so a reader names the row as it moves along it, and the row's checkbox, expander and actions are named after it too. A sortable header holds a button, and its cell carries `aria-sort`, so the reader says which column orders the rows and in which direction. While `loading` is set the table is `aria-busy`. A value cut short at the edge of its cell shows whole in a tip, under the pointer and on keyboard focus alike; the cell's text is the whole value all along, so a reader never meets the cut. #### API ##### `TableProps` | Prop | Type | Default | Description | |---|---|---|---| | `selectable` | `boolean` | — | Adds the selection column. "All" means: the filtered set.
Shown in: [Name the row (Column)](#/column/row-header), [Pin columns to both sides (Width and pinning)](#/width-and-pinning/pinned-both-sides), [Keep the header and the names in view (Width and pinning)](#/width-and-pinning/sticky-parts) and 14 more | | `stickyHeader` | `boolean` | — | Keeps the header row visible while scrolling.
Shown in: [Keep the header and the names in view (Width and pinning)](#/width-and-pinning/sticky-parts), [Fold groups into a summary (Grouping)](#/grouping/the-overview), [Group twenty thousand rows (Grouping)](#/grouping/twenty-thousand-rows) and 4 more | | `stickyRowHeader` | `boolean` | — | Keeps the row header column visible while scrolling sideways - the same as `pin="start"` on the row header.
Shown in: [Keep the header and the names in view (Width and pinning)](#/width-and-pinning/sticky-parts), [Stripes across a wide table (Row appearance)](#/row-appearance/stripes-across-a-wide-table), [Show, hide and arrange (ColumnMenu)](#/columnmenu/show-hide-and-arrange) and 1 more | | `striped` | `boolean` | — | Discreet zebra stripes.
Shown in: [Stripes across a wide table (Row appearance)](#/row-appearance/stripes-across-a-wide-table) | | `density` | `"regular" \| "compact"` | the density of the `UmrissProvider`, else `"regular"` | How tightly the rows stand.
Shown in: [Fit long group names in a compact table (Grouping)](#/grouping/compact-and-long-values), [Employees in the detail (Tree rows)](#/tree-rows/employees-in-the-detail), [Own staff and teams (Tree rows)](#/tree-rows/own-staff-and-teams) and 4 more | | `maxHeight` | `string` | — | Height of the scroll area, e.g. "480px". A virtualised table needs it.
Shown in: [Keep the header and the names in view (Width and pinning)](#/width-and-pinning/sticky-parts), [Fold groups into a summary (Grouping)](#/grouping/the-overview), [Group twenty thousand rows (Grouping)](#/grouping/twenty-thousand-rows) and 4 more | | `empty` | `ReactNode` | — | What stands in the body when there are no rows. Empty because of a filter the table shows itself – with the way back.
Shown in: [Restrict which rows a table has (Pre-filter)](#/pre-filter/pre-filter), [Show loading, empty, failed and no match (Row appearance)](#/row-appearance/states) | | `loading` | `boolean` | — | Shows placeholder rows instead of the rows.
Shown in: [Rows from a request (Filter)](#/filter/rows-from-a-request), [Show loading, empty, failed and no match (Row appearance)](#/row-appearance/states), [Show loading, and drop a late answer (Server mode)](#/server-mode/loading-and-late-answers) and 1 more | | `rowProps` | `(row: Z) => `[`RowAttributes`](#type-RowAttributes) | — | Classes and data attributes per row.
Shown in: [Mark rows by their state (Row appearance)](#/row-appearance/styling-rows) | | `groupable` | `boolean` | — | `false`: the user cannot group this table - the column menu offers no grouping, and a grouping handed in is passed over.
Shown in: [Switch grouping off (Grouping)](#/grouping/switched-off) | | `grid` | `boolean` | — | Grid mode: the table is one tab stop, and the arrows walk its cells - Home and End in the row, Ctrl+Home and Ctrl+End to the table's ends, PageUp and PageDown by the rows in view. A cell's own controls are reached with Enter or F2 and left with Escape; a column with `edit` is edited in place. Without it the table stays a native table.
Shown in: [Work cell by cell (Grid mode)](#/grid-mode/grid-mode), [Combine grid mode with groups, pinning and many rows (Grid mode)](#/grid-mode/the-whole-grid), [Edit a comments column (Edits)](#/edits/comments-column) and 6 more | | `editMode` | `"cell" \| "row"` | — | Grid mode ([ADR-0036](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0036-a-grid-edits-on-the-first-click-and-saves-rows-on-purpose.md)): `"cell"` reports every cell as it is left (`onCellEdit`); `"row"` opens every cell of a row that edits at once, as one **Row draft**, and reports it only when the row is saved on purpose (`onRowSave`) - by its Save button or Enter; Escape or Discard drops it. A row with a draft is left only by saving or discarding it.
Shown in: [Save a row on purpose (Edits)](#/edits/save-a-row-on-purpose), [Check a whole row before it is saved (Edits)](#/edits/check-a-whole-row), [Keep a list: add, change and delete rows (Edits)](#/edits/keep-a-list) and 1 more | | `newRow` | `() => Partial` | — | What a new row starts with - the values the columns read before anything is typed. Without it, nothing.
Shown in: [Keep a list: add, change and delete rows (Edits)](#/edits/keep-a-list) | | `children` | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts.
Shown in: [Show a list of services](#/first-table/first-table), [Search and page from a header of your own](#/first-table/search-and-page-from-a-header-of-your-own), [Sort, arrange and export from controls of your own](#/first-table/sort-arrange-and-export-from-controls-of-your-own) and 118 more | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | `onCellEdit` | `(edit: `[`CellEdit`](#type-CellEdit)`) => void` | — | Grid mode: an edit the user committed. The table applies nothing; the cell shows the new value when the rows passed in carry it.
Shown in: [Combine grid mode with groups, pinning and many rows (Grid mode)](#/grid-mode/the-whole-grid), [Edit a comments column (Edits)](#/edits/comments-column), [Check an edit before it is reported (Edits)](#/edits/validated-edits) and 1 more | | `onRowSave` | `(save: `[`RowSave`](#type-RowSave)`) => void` | — | `editMode="row"`: a saved Row draft, with the columns it changed.
Shown in: [Save a row on purpose (Edits)](#/edits/save-a-row-on-purpose), [Check a whole row before it is saved (Edits)](#/edits/check-a-whole-row), [Keep a list: add, change and delete rows (Edits)](#/edits/keep-a-list) and 1 more | | `onRowAdd` | `(add: `[`RowAdd`](#type-RowAdd)`) => void` | — | Grid mode: puts up a "New row" button - in the table toolbar, or beneath the table without one. It opens an empty Row draft above the rows; saved, it is reported whole. The row appears when the rows passed in carry it.
Shown in: [Keep a list: add, change and delete rows (Edits)](#/edits/keep-a-list) | | `onRowDelete` | `(remove: `[`RowDelete`](#type-RowDelete)`) => void` | — | Grid mode: a Delete button on every row, which asks inside its row before the delete is reported. The table removes nothing.
Shown in: [Keep a list: add, change and delete rows (Edits)](#/edits/keep-a-list), [Edit a dense table (Edits)](#/edits/edit-a-dense-table) | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | `ariaLabel` | `string` | — | Accessible name of the table (not of the outer frame).
Shown in: [Show a list of services](#/first-table/first-table), [Search and page from a header of your own](#/first-table/search-and-page-from-a-header-of-your-own), [Sort, arrange and export from controls of your own](#/first-table/sort-arrange-and-export-from-controls-of-your-own) and 118 more | ###### Styling | Prop | Type | Default | Description | |---|---|---|---| | `className` | `string` | — | Classes on the outer frame of the table.
Shown in: [Pin columns to both sides (Width and pinning)](#/width-and-pinning/pinned-both-sides), [Keep the header and the names in view (Width and pinning)](#/width-and-pinning/sticky-parts), [Pin and size from controls of your own (Width and pinning)](#/width-and-pinning/pin-and-size-from-controls-of-your-own) and 2 more | ##### `TableOptions` | Prop | Type | Default | Description | |---|---|---|---| | `rowKey` *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion.
Shown in: [Show a list of services](#/first-table/first-table), [Search and page from a header of your own](#/first-table/search-and-page-from-a-header-of-your-own), [Sort, arrange and export from controls of your own](#/first-table/sort-arrange-and-export-from-controls-of-your-own) and 118 more | | `pageSize` | `number` | `10` | How many rows a page holds.
Shown in: [Search and page from a header of your own](#/first-table/search-and-page-from-a-header-of-your-own), [Ask a question over several fields (Filter)](#/filter/a-question-over-several-fields), [Restrict which rows a table has (Pre-filter)](#/pre-filter/pre-filter) and 7 more | | `defaultSort` | [`SortLevel`](#type-SortLevel)` \| readonly `[`SortLevel`](#type-SortLevel)`[] \| null` | `null` | The application's sort; it is the default that `view` leaves out.
Shown in: [Sort, arrange and export from controls of your own](#/first-table/sort-arrange-and-export-from-controls-of-your-own), [A label wider than its column (Width and pinning)](#/width-and-pinning/a-label-wider-than-its-column), [Sort by several columns (Sorting)](#/sorting/sort-by-several-columns) and 10 more | | `defaultGrouping` | [`GroupingId`](#type-GroupingId)` \| readonly `[`GroupingId`](#type-GroupingId)`[]` | ungrouped, until the user groups | The application's grouping: a column or group key, or up to three, the outermost first. It is the default that `view` leaves out.
Shown in: [Group by one column (Grouping)](#/grouping/one-level), [Group on two levels (Grouping)](#/grouping/two-levels), [Group on three levels (Grouping)](#/grouping/three-levels) and 23 more | | `preFilter` | `(row: Z) => boolean` | — | Which rows the table has at all – by permission, by tenant, by anything the user is not meant to undo. It is invisible: never a condition, never reset, never part of the view, and "43 of 1,204" counts only the rows it admits. It may stand in the call; a change leaves the page standing, and the model clamps it.
Shown in: [Restrict which rows a table has (Pre-filter)](#/pre-filter/pre-filter) | | `rowFilters` | `readonly `[`RowFilter`](#/filter/type-RowFilter)`[]` | — | The row filters the table has: conditions the application defines over the whole row and sets with `t.setFilter(filter, condition)` from a control of its own. Named here so that a condition from `initialView`, which arrives by id, is known in the first render.
Shown in: [Filter by a field no column shows (Filter)](#/filter/a-field-no-column-shows), [Rows from a request (Filter)](#/filter/rows-from-a-request), [Ask a question over several fields (Filter)](#/filter/a-question-over-several-fields) and 3 more | | `initialView` | [`TableView`](#type-TableView) | — | The view to start from, read once when the hook first runs; a different value on a later render is ignored, as React ignores a changed `defaultValue`. To go to a view later, call `setView`. The table remembers none: where a view is kept is the application's decision. Names no column carries fall out.
Shown in: [Ask a question over several fields (Filter)](#/filter/a-question-over-several-fields), [Fold groups into a summary (Grouping)](#/grouping/the-overview), [Show loading, empty, failed and no match (Row appearance)](#/row-appearance/states) and 2 more | | `virtual` | `boolean \| { overscan?: number }` | — | Renders only what stands in the scroll area; paging is then off. Every row is one row pitch tall, which the table measures itself ([ADR-0042](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0042-a-rows-height-never-follows-what-it-shows.md)).
Shown in: [Group twenty thousand rows (Grouping)](#/grouping/twenty-thousand-rows), [Scroll twenty thousand rows (Virtualisation)](#/virtualisation/virtualisation), [Measure two hundred thousand rows (Benchmark)](#/benchmark/benchmark) and 1 more | | `selection` | [`TableSelection`](#type-TableSelection)`` | — | A selection from outside instead of its own – when the application holds it.
Shown in: [Hold the selection yourself (Selection)](#/selection/hold-the-selection-yourself) | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | `onViewChange` | `(view: `[`TableView`](#type-TableView)`) => void` | — | Every change the reader makes, once, always the whole view - the one to keep, or to hand another table's `setView`. Not called for the view the table starts with, nor for one `setView` applies.
Shown in: [Keep and restore a view (View)](#/view/initial-view) | ##### `TableSnapshot` | Prop | Type | Default | Description | |---|---|---|---| | `rows` *required* | `readonly Z[]` | — | All the rows passed in, including those the pre-filter does not admit.
Shown in: [Search and page from a header of your own](#/first-table/search-and-page-from-a-header-of-your-own) | | `filtered` *required* | `readonly Z[]` | — | The filtered set: what the pre-filter admits and search and filters leave, across all pages.
Shown in: [Select rows (Selection)](#/selection/selection), [Scroll twenty thousand rows (Virtualisation)](#/virtualisation/virtualisation) | | `visible` *required* | `readonly Z[]` | — | What is rendered right now: the page or the window.
Shown in: [Scroll twenty thousand rows (Virtualisation)](#/virtualisation/virtualisation) | | `page` *required* | `number` | — | The page shown, from 1. Without `Pagination` always 1.
Shown in: [Search and page from a header of your own](#/first-table/search-and-page-from-a-header-of-your-own) | | `pageCount` *required* | `number` | — | How many pages the filtered set makes.
Shown in: [Search and page from a header of your own](#/first-table/search-and-page-from-a-header-of-your-own) | | `pageSize` *required* | `number` | — | Rows per page.
Shown in: [Search and page from a header of your own](#/first-table/search-and-page-from-a-header-of-your-own) | | `setPage` *required* | `(page: number) => void` | — | Changes the page, clamped to those that exist.
Shown in: [Search and page from a header of your own](#/first-table/search-and-page-from-a-header-of-your-own) | | `setPageSize` *required* | `(size: number) => void` | — | Sets the rows per page; back to page one.
Shown in: [Search and page from a header of your own](#/first-table/search-and-page-from-a-header-of-your-own) | | `search` *required* | `string` | — | The search text.
Shown in: [Search and page from a header of your own](#/first-table/search-and-page-from-a-header-of-your-own) | | `setSearch` *required* | `(search: string) => void` | — | Sets the search text; back to page one.
Shown in: [Search and page from a header of your own](#/first-table/search-and-page-from-a-header-of-your-own) | | `sort` *required* | `readonly `[`SortLevel`](#type-SortLevel)`[]` | — | The sort levels, the first one first – only over columns that registered.
Shown in: [Sort, arrange and export from controls of your own](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | | `toggleSort` *required* | `(column: string, additive?: boolean) => void` | — | Sorts by a column. With `additive` a level is added instead of replacing the sort.
Shown in: [Sort, arrange and export from controls of your own](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | | `hidden` *required* | `readonly string[]` | — | The ids of the hidden columns.
Shown in: [Sort, arrange and export from controls of your own](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | | `toggleColumn` *required* | `(column: string) => void` | — | Hides a column or shows it again. The row header stays.
Shown in: [Sort, arrange and export from controls of your own](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | | `order` *required* | `readonly string[]` | — | The order of the columns as ids, as the user chose it.
Shown in: [Sort, arrange and export from controls of your own](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | | `setOrder` *required* | `(order: readonly string[]) => void` | — | Reorders the columns – on header, body and footer at once.
Shown in: [Sort, arrange and export from controls of your own](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | | `pinned` *required* | `Readonly>` | — | The pinned columns by id - the declared ones until the user chooses.
Shown in: [Pin and size from controls of your own (Width and pinning)](#/width-and-pinning/pin-and-size-from-controls-of-your-own) | | `setPin` *required* | `(column: string, pin: `[`Pin`](#type-Pin)` \| null) => void` | — | Pins a column to the start or the end, or unpins it with `null`.
Shown in: [Pin and size from controls of your own (Width and pinning)](#/width-and-pinning/pin-and-size-from-controls-of-your-own) | | `widths` *required* | `Readonly>` | — | The widths in pixels: those of the columns, and over them the dragged ones.
Shown in: [Pin and size from controls of your own (Width and pinning)](#/width-and-pinning/pin-and-size-from-controls-of-your-own) | | `setWidth` *required* | `(column: string, width: number \| undefined) => void` | — | Sets the width of a column; `undefined` takes it back.
Shown in: [Pin and size from controls of your own (Width and pinning)](#/width-and-pinning/pin-and-size-from-controls-of-your-own) | | `expanded` *required* | `readonly string[]` | — | The keys of the expanded rows.
Shown in: [Open a detail from outside the table (RowDetail)](#/rowdetail/open-a-detail-from-outside) | | `toggleRow` *required* | `(key: string) => void` | — | Expands a row or collapses it.
Shown in: [Open a detail from outside the table (RowDetail)](#/rowdetail/open-a-detail-from-outside) | | `filter` *required* | `Readonly>` | — | The conditions of the column filters and row filters by id, in the order in which they were set. Whatever fits no column and no row filter is absent.
Shown in: [Set a condition from outside the table (Filter)](#/filter/conditions-from-outside) | | `setFilter` *required* | [`SetFilter`](#type-SetFilter)`` | — | Sets the condition of a column filter or a row filter; `null` clears it. Back to page one. A condition that does not fit the kind of the filter – a range on a list filter, a column without a filter, a row filter not in `rowFilters` – is passed over with a warning in development.
Shown in: [Set a condition from outside the table (Filter)](#/filter/conditions-from-outside), [Filter by a field no column shows (Filter)](#/filter/a-field-no-column-shows), [Rows from a request (Filter)](#/filter/rows-from-a-request) and 5 more | | `conditionOf` *required* | `(filter: `[`RowFilter`](#/filter/type-RowFilter)`) => B \| null` | — | The condition of a row filter, as its type - `null` while it is lifted.
Shown in: [Filter by a field no column shows (Filter)](#/filter/a-field-no-column-shows), [Rows from a request (Filter)](#/filter/rows-from-a-request), [Ask a question over several fields (Filter)](#/filter/a-question-over-several-fields) and 3 more | | `selection` *required* | [`TableSelection`](#type-TableSelection)`` | — | The selection – its own or the one handed in through `selection`.
Shown in: [Select rows (Selection)](#/selection/selection), [Copy the selection (Export)](#/export/copy-the-selection), [Scroll twenty thousand rows (Virtualisation)](#/virtualisation/virtualisation) and 3 more | | `grouping` *required* | `readonly string[]` | — | The ids the table is grouped by, the outermost first – only those a groupable column or a group key carries, at most three.
Shown in: [Group and fold from controls of your own (Grouping)](#/grouping/group-and-fold-from-controls-of-your-own) | | `setGrouping` *required* | `(grouping: readonly string[]) => void` | — | Groups by these columns or group keys; `[]` ungroups. Back to page one.
Shown in: [Group and fold from controls of your own (Grouping)](#/grouping/group-and-fold-from-controls-of-your-own) | | `setView` *required* | `(view: `[`TableView`](#type-TableView)`) => void` | — | Goes to a whole view; what it leaves out goes back to where the table starts, and names no column carries fall out. Not reported through `onViewChange`: two tables kept in step by `onViewChange: (v) => other.setView(v)` in both directions do not answer each other. A view with the content already shown changes nothing.
Shown in: [Keep and restore a view (View)](#/view/initial-view) | | `folded` *required* | `readonly string[]` | — | The paths of the folded groups that still occur.
Shown in: [Group and fold from controls of your own (Grouping)](#/grouping/group-and-fold-from-controls-of-your-own) | | `toggleFold` *required* | `(path: string) => void` | — | Folds a group or unfolds it. A group of one row does not fold.
Shown in: [Group and fold from controls of your own (Grouping)](#/grouping/group-and-fold-from-controls-of-your-own) | | `foldAll` *required* | `() => void` | — | Folds every group that has more than one row.
Shown in: [Fold groups into a summary (Grouping)](#/grouping/the-overview) | | `unfoldAll` *required* | `() => void` | — | Unfolds every group.
Shown in: [Fold groups into a summary (Grouping)](#/grouping/the-overview) | | `branches` *required* | `readonly string[]` | — | Tree rows: the keys of the open branches.
Shown in: [Open branches from controls of your own (Tree rows)](#/tree-rows/open-branches-from-controls-of-your-own) | | `toggleBranch` *required* | `(key: string) => void` | — | Tree rows: opens a branch or closes it.
Shown in: [Open branches from controls of your own (Tree rows)](#/tree-rows/open-branches-from-controls-of-your-own) | | `unfoldAllBranches` *required* | `() => void` | — | Tree rows: opens every branch that has children.
Shown in: [Open branches from controls of your own (Tree rows)](#/tree-rows/open-branches-from-controls-of-your-own) | | `foldAllBranches` *required* | `() => void` | — | Tree rows: closes every branch.
Shown in: [Open branches from controls of your own (Tree rows)](#/tree-rows/open-branches-from-controls-of-your-own) | | `view` *required* | [`TableView`](#type-TableView) | — | The part of the state an application can keep; whatever still stands as the options set it up is absent.
Shown in: [Set a condition from outside the table (Filter)](#/filter/conditions-from-outside), [Fold groups into a summary (Grouping)](#/grouping/the-overview), [Keep and restore a view (View)](#/view/initial-view) | | `asCsv` *required* | `() => string` | — | The filtered set in the visible columns as text for a spreadsheet - in server mode the page.
Shown in: [Sort, arrange and export from controls of your own](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | | `selectionAsText` *required* | `() => string` | — | The selected rows within the filtered set as tab-separated text under a header line - the text Ctrl+C writes. `""` with nothing selected.
Shown in: [Copy the selection (Export)](#/export/copy-the-selection) | | `virtual` *required* | `boolean` | — | Whether it virtualises.
Shown in: [Search and page from a header of your own](#/first-table/search-and-page-from-a-header-of-your-own) | | `rowCount` *required* | `number` | — | How many rows the filtered set has - in server mode the server's count.
Shown in: [Search and page from a header of your own](#/first-table/search-and-page-from-a-header-of-your-own) | | `server` *required* | `boolean` | — | Whether it is in server mode: the rows are one page of a server's.
Shown in: [Sort, arrange and export from controls of your own](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | ##### Types on this page ###### `RowAttributes` What a row carries in classes and data attributes. ```ts type RowAttributes = { className?: string } & { [data: `data-${string}`]: string | undefined; }; ``` ###### `CellEdit` An edit the user committed - reported, not applied. | Prop | Type | Default | Description | |---|---|---|---| | `rowKey` *required* | `string` | — |
Shown in: [Combine grid mode with groups, pinning and many rows (Grid mode)](#/grid-mode/the-whole-grid), [Edit a comments column (Edits)](#/edits/comments-column), [Check an edit before it is reported (Edits)](#/edits/validated-edits) and 1 more | | `columnId` *required* | `string` | — |
Shown in: [Combine grid mode with groups, pinning and many rows (Grid mode)](#/grid-mode/the-whole-grid), [Check an edit before it is reported (Edits)](#/edits/validated-edits), [Plan the team's capacity (Scenarios)](#/scenarios/plan-the-team-capacity) | | `value` *required* | `unknown` | — | The editor's value: text for `"text"`, a number or null for `"number"`, a day or null for `"date"`, an option for `"select"`.
Shown in: [Combine grid mode with groups, pinning and many rows (Grid mode)](#/grid-mode/the-whole-grid), [Edit a comments column (Edits)](#/edits/comments-column), [Check an edit before it is reported (Edits)](#/edits/validated-edits) and 1 more | | `row` *required* | `Z` | — |
Shown in: [Check an edit before it is reported (Edits)](#/edits/validated-edits), [Plan the team's capacity (Scenarios)](#/scenarios/plan-the-team-capacity) | ###### `RowSave` A Row draft the user saved ([ADR-0036](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0036-a-grid-edits-on-the-first-click-and-saves-rows-on-purpose.md)) - reported, not applied. | Prop | Type | Default | Description | |---|---|---|---| | `rowKey` *required* | `string` | — |
Shown in: [Save a row on purpose (Edits)](#/edits/save-a-row-on-purpose), [Check a whole row before it is saved (Edits)](#/edits/check-a-whole-row), [Keep a list: add, change and delete rows (Edits)](#/edits/keep-a-list) and 1 more | | `changes` *required* | `Readonly>` | — | The columns the draft changed, by column id, with their editors' values.
Shown in: [Save a row on purpose (Edits)](#/edits/save-a-row-on-purpose), [Check a whole row before it is saved (Edits)](#/edits/check-a-whole-row), [Keep a list: add, change and delete rows (Edits)](#/edits/keep-a-list) and 1 more | | `row` *required* | `Z` | — |
Shown in: [Save a row on purpose (Edits)](#/edits/save-a-row-on-purpose) | ###### `RowAdd` A new row the user saved: every column that edits, by column id. | Prop | Type | Default | Description | |---|---|---|---| | `values` *required* | `Readonly>` | — |
Shown in: [Keep a list: add, change and delete rows (Edits)](#/edits/keep-a-list) | ###### `RowDelete` A row the user asked to delete, and confirmed. | Prop | Type | Default | Description | |---|---|---|---| | `rowKey` *required* | `string` | — |
Shown in: [Keep a list: add, change and delete rows (Edits)](#/edits/keep-a-list), [Edit a dense table (Edits)](#/edits/edit-a-dense-table) | | `row` *required* | `Z` | — | | ###### `SortLevel` One level of the sort. Several levels act in their order: the second decides only what the first leaves open. | Prop | Type | Default | Description | |---|---|---|---| | `column` *required* | `K` | — |
Shown in: [Sort, arrange and export from controls of your own](#/first-table/sort-arrange-and-export-from-controls-of-your-own), [A label wider than its column (Width and pinning)](#/width-and-pinning/a-label-wider-than-its-column), [Sort by several columns (Sorting)](#/sorting/sort-by-several-columns) and 10 more | | `direction` *required* | [`SortDirection`](#type-SortDirection)
`"asc" \| "desc"` | — |
Shown in: [Sort, arrange and export from controls of your own](#/first-table/sort-arrange-and-export-from-controls-of-your-own), [A label wider than its column (Width and pinning)](#/width-and-pinning/a-label-wider-than-its-column), [Sort by several columns (Sorting)](#/sorting/sort-by-several-columns) and 10 more | ###### `GroupingId` A column or group key the table can be grouped by. The columns stand in the JSX, which the hook does not see: a field name is suggested, any id is taken, and one that nothing carries falls out. ```ts type GroupingId = Field | (string & {}); ``` ###### `TableView` Search, conditions, sort levels, page, page size, hidden columns, order, dragged widths, the grouping and the pinned columns. Whatever is at its default is absent. The pre-filter never belongs to it: it is a function of the application. | Prop | Type | Default | Description | |---|---|---|---| | `search` | `string` | — |
Shown in: [Show loading, empty, failed and no match (Row appearance)](#/row-appearance/states), [Hand the table one page (Server mode)](#/server-mode/only-pages), [Sort and search on the server (Server mode)](#/server-mode/sort-and-search) and 3 more | | `conditions` | `Readonly>` | — | The conditions of the column filters, per column. A condition for a column that does not exist, or whose filter is of another kind, falls out.
Shown in: [Ask a question over several fields (Filter)](#/filter/a-question-over-several-fields), [A dispatcher's bar (Toolbar controls)](#/toolbar-controls/a-dispatcher-s-bar), [Hand the table one page (Server mode)](#/server-mode/only-pages) and 4 more | | `sort` | `readonly `[`SortLevel`](#type-SortLevel)`[]` | — |
Shown in: [Keep and restore a view (View)](#/view/initial-view), [Hand the table one page (Server mode)](#/server-mode/only-pages), [Sort and search on the server (Server mode)](#/server-mode/sort-and-search) and 3 more | | `page` | `number` | — |
Shown in: [Hand the table one page (Server mode)](#/server-mode/only-pages), [Sort and search on the server (Server mode)](#/server-mode/sort-and-search), [Name the values a list filter offers (Server mode)](#/server-mode/filter-options) and 2 more | | `pageSize` | `number` | — |
Shown in: [Hand the table one page (Server mode)](#/server-mode/only-pages), [Sort and search on the server (Server mode)](#/server-mode/sort-and-search), [Name the values a list filter offers (Server mode)](#/server-mode/filter-options) and 2 more | | `hidden` | `readonly K[]` | — |
Shown in: [Keep and restore a view (View)](#/view/initial-view) | | `order` | `readonly K[]` | — | | | `widths` | `Readonly>` | — | Column widths the user has dragged, in pixels. | | `grouping` | `readonly K[]` | — | The columns and group keys the table is grouped by, the outermost first. | | `folded` | `readonly string[]` | — | The paths of the folded groups. A path that no longer occurs falls out.
Shown in: [Fold groups into a summary (Grouping)](#/grouping/the-overview) | | `branches` | `readonly string[]` | — | Tree rows: the keys of the open branches. A key that no longer occurs falls out. | | `pinned` | `Readonly>` | — | The pinned columns, whole, once the user's choice deviates from what the columns declare - `{}` when every declared pin was undone. A column that does not exist falls out. | ###### `TableSelection` What `useTableSelection` returns: the selected keys and the gestures on them. Give it to `useTable` as `selection` when the application holds the selection. | Prop | Type | Default | Description | |---|---|---|---| | `selected` *required* | `ReadonlySet` | — | The keys currently selected.
Shown in: [Work through the alerts (Scenarios)](#/scenarios/work-through-the-alerts) | | `isSelected` *required* | `(id: T) => boolean` | — |
Shown in: [Select rows (Selection)](#/selection/selection), [Hold the selection yourself (Selection)](#/selection/hold-the-selection-yourself) | | `toggle` *required* | `(id: T) => void` | — |
Shown in: [Hold the selection yourself (Selection)](#/selection/hold-the-selection-yourself) | | `toggleAll` *required* | `() => void` | — | Selects all the keys passed in - or, when all of them are selected already, deselects them. Keys outside them stay as they are. | | `clear` *required* | `() => void` | — |
Shown in: [Hold the selection yourself (Selection)](#/selection/hold-the-selection-yourself), [Work through the alerts (Scenarios)](#/scenarios/work-through-the-alerts), [Find a late shipment (Scenarios)](#/scenarios/find-a-late-shipment) | | `allSelected` *required* | `boolean` | — | true when every key passed in is selected. | | `someSelected` *required* | `boolean` | — | true when some - but not all - are selected. | | `count` *required* | `number` | — |
Shown in: [Select rows (Selection)](#/selection/selection), [Hold the selection yourself (Selection)](#/selection/hold-the-selection-yourself), [Copy the selection (Export)](#/export/copy-the-selection) and 2 more | ###### `Pin` Where a pinned column stands: in the block before or after the others. ```ts type Pin = "start" | "end"; ``` ###### `SetFilter` Sets a condition from outside. Typed at the field of the row, not at the column: the columns stand in the JSX, and the hook does not see that ([ADR-0017](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0017-columns-are-declared-by-composition.md)). An id that is not a field name – a computed column or an `id` of one's own – takes any condition; through it the condition of a filter of one's own is set as well. ```ts interface SetFilter { (filter: RowFilter, condition: B | null): void; >(column: K, condition: FieldCondition | null): void; (column: I & (I extends Field ? never : unknown), condition: unknown): void; } ``` ###### `SortDirection` The direction of one sort level: `asc` puts the smallest value on top, `desc` the largest. ```ts type SortDirection = "asc" | "desc"; ``` ###### `Field` The field names of a row. ```ts type Field = Extract; ``` ###### `FieldCondition` What can be set as a condition on a field: a list of its values, and for numbers and points in time a range as well. ```ts type FieldCondition = | ListCondition | ([Present] extends [number] ? RangeCondition : [Present] extends [Date] ? RangeCondition : never); ``` ###### `ListCondition` The condition of a list filter: the chosen values, an absent one as `null`. ```ts type ListCondition = readonly (Present | null)[]; ``` ###### `Present` The value without the absence - that is what `children` gets. ```ts type Present = Exclude; ``` ###### `RangeCondition` The condition of a range filter: both bounds inclusive, each may be absent. | Prop | Type | Default | Description | |---|---|---|---| | `from` | `W` | — | | | `to` | `W` | — | | ###### `Absent` A value that is not there: `null` or `undefined`. ```ts type Absent = null | undefined; ``` #### Known limits - No cell range selection and no undo: an edit is reported, never applied, and the application owns the rows ([ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/)). - No pivot: compute the pivoted rows and declare their columns ([ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/)). What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### Provider Set the table's wording, formats and density once for the whole application, at the core package's `UmrissProvider`. ```ts import { UmrissProvider } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/provider/ The table has no provider of its own: it reads the one of @umriss-ui/core ([ADR-0016](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0016-the-table-is-a-package-that-depends-on-ui.md)). German wording ships as `@umriss-ui/core/wording/de` ([ADR-0019](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0019-two-wordings-ship-english-is-the-default.md)). #### Examples ##### Set wording and formats once The table's texts and formats come from the core `UmrissProvider`: one `language` object sets them for the table and the date picker alike. ```tsx import { useState } from "react"; import { DatePicker, FormField, UmrissProvider, Stack } from "@umriss-ui/core"; import type { LanguageOptions } from "@umriss-ui/core"; import { ColumnMenu, Export, Search, Toolbar, useTable } from "@umriss-ui/table"; /* Outside the component: a provider that gets a new object on every render recomputes on every render. */ const LANGUAGE: LanguageOptions = { formats: { date: (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`, }, wording: { columns: "View", exportLabel: "Save as CSV", tableSearchPlaceholder: "Search invoice …", }, }; interface Invoice { id: string; supplier: string; received: Date; } const INVOICES: Invoice[] = [ { id: "INV-26-0318", supplier: "Brandlow Office Supply", received: new Date(2026, 2, 16) }, { id: "INV-26-0317", supplier: "Kettering & Shaw Events", received: new Date(2026, 2, 13) }, { id: "INV-26-0309", supplier: "Nimbrel Software", received: new Date(2026, 2, 9) }, ]; function Invoices() { const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id }); return (
); } export default function Provider() { const [from, setFrom] = useState(new Date(2026, 2, 9)); return ( ); } ``` #### Keyboard The keys of [First table](#/first-table/keyboard-first-table), [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input), [ColumnMenu](#/columnmenu/keyboard-columnmenu), [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button) and [DatePicker](https://romanhaendler.github.io/umriss-ui/core/datepicker/#keyboard-datepicker) apply here. #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | | [`format`](#/column/FieldColumn-format) | [`FormatFor`](#/column/type-FormatFor)`` | — | A standard presentation by name: `"percent"`, `"count"` or `{ decimals }` for numbers, `"date"`, `"time"` or `"dateTime"` for points in time. Alignment, sorting, export and footer stay with the value. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | From `ToolbarProps` — the full table stands on [Toolbar](#/toolbar/type-ToolbarProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/toolbar/ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. | From `ExportProps` — the full table stands on [Export](#/export/type-ExportProps). | Prop | Type | Default | Description | |---|---|---|---| | [`filename`](#/export/ExportProps-filename) | `string` | the wording's "table.csv" | File name of the download. | ## Columns What a column reads, how it shows it, and how wide it stands. ### Column One value per row, read from a field or computed. Everything the table does with a column (sort, search, export, total) works on that value, never on what the cell shows. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/column/ Without a presentation the value type decides: text on the left, numbers right-aligned with tabular figures, points in time as date and time, truth values as a word. A computed value needs an `id`. One column is the row header: it names the row, cannot be hidden, and the row's checkbox and actions are read out with it. A computed `value` (like `sortValue`, `groupValue` and `rowKey`) may be written inline: a new function with the same source text counts as the same one, and nothing is recomputed. A closure that reads an outer variable which has changed reads as unchanged; give the function a new text, or carry the variable in the row data. #### Examples ##### Read a value from a field `value` names a field of the row, and the compiler offers only the fields there are; the field name is the column's id as well. ```tsx import { useTable } from "@umriss-ui/table"; interface Vehicle { plate: string; type: string; capacity: number; } const VEHICLES: Vehicle[] = [ { plate: "FP 214 K", type: "van", capacity: 1200 }, { plate: "FP 377 K", type: "e-van", capacity: 900 }, { plate: "FP 118 R", type: "truck", capacity: 7500 }, ]; export default function ValueFromField() { const { Table, Column } = useTable(VEHICLES, { rowKey: (v) => v.plate }); return (
); } ``` ##### Compute a value from the row When `value` is a function the column needs an `id`; the computed value sorts, formats and exports like any other. ```tsx import { useTable } from "@umriss-ui/table"; interface Line { description: string; quantity: number; unitPrice: number; } const LINES: Line[] = [ { description: "Desk lamps, LED", quantity: 24, unitPrice: 48.9 }, { description: "Printer paper, box of 5 reams", quantity: 40, unitPrice: 21.5 }, { description: "Delivery", quantity: 1, unitPrice: 35 }, ]; export default function ComputedValue() { const { Table, Column } = useTable(LINES, { rowKey: (l) => l.description }); return ( l.quantity * l.unitPrice} format={{ decimals: 2 }} />
); } ``` ##### Let the value type decide Without `children` the value decides: text left, numbers right with tabular figures, dates as date and time, booleans as a word. ```tsx import { useTable } from "@umriss-ui/table"; interface Service { name: string; requests: number; deployed: Date; pages: boolean; openIncidents: number | null; } const SERVICES: Service[] = [ { name: "Checkout", requests: 1840.5, deployed: new Date(2026, 2, 17, 9, 5), pages: true, openIncidents: 1 }, { name: "Reporting", requests: 18, deployed: new Date(2026, 2, 16, 14, 30), pages: false, openIncidents: null }, ]; export default function Defaults() { const { Table, Column } = useTable(SERVICES, { rowKey: (s) => s.name }); return (
); } ``` ##### Name the row `rowHeader` makes a column the row's name: it cannot be hidden, and the row's checkbox and actions are read out with it. ```tsx import { useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { ColumnMenu, Toolbar, useTable } from "@umriss-ui/table"; interface Incident { id: string; title: string; } const INCIDENTS: Incident[] = [ { id: "INC-1048", title: "Checkout slow, card payments time out" }, { id: "INC-1047", title: "Webhook deliveries delayed" }, { id: "INC-1046", title: "Thumbnails missing for new uploads" }, ]; export default function RowHeader() { const [last, setLast] = useState("Nothing opened yet."); const { Table, Column, RowActions, Action } = useTable(INCIDENTS, { rowKey: (i) => i.id }); return ( setLast(`${i.id} opened`)}>Open
{last}
); } ``` #### When to use something else - Restricting a column to some of its values → Filter - A group key that should not be a column → Grouping - A measured value read against a limit set → VerdictColumn #### Keyboard The keys of [First table](#/first-table/keyboard-first-table), [ColumnMenu](#/columnmenu/keyboard-columnmenu), [RowActions](#/rowactions/keyboard-rowactions) and [Checkbox](https://romanhaendler.github.io/umriss-ui/core/checkbox/#keyboard-checkbox) apply here. #### Accessibility The `label` is the column header's text, and so what a screen reader says for every cell under it. The `rowHeader` column's cells are row headers: a reader names the row by them, and the row's checkbox and actions are named after them - “Select INC-1048”, “Open: INC-1048”. Without one, each of those says only its own word. #### API ##### `FieldColumn>` | Prop | Type | Default | Description | |---|---|---|---| | `value` *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time.
Shown in: [Read a value from a field](#/column/value-from-field), [Compute a value from the row](#/column/computed-value), [Let the value type decide](#/column/defaults) and 118 more | | `id` | `string` | — | An id of one's own instead of the field name – for two columns over the same field.
Shown in: [Pick a standard format (Formats)](#/formats/format), [Share a column between tables (Presets)](#/presets/presets) | | `format` | [`FormatFor`](#type-FormatFor)`` | — | A standard presentation by name: `"percent"`, `"count"` or `{ decimals }` for numbers, `"date"`, `"time"` or `"dateTime"` for points in time. Alignment, sorting, export and footer stay with the value.
Shown in: [Compute a value from the row](#/column/computed-value), [Set wording and formats once (Provider)](#/provider/provider), [Pick a standard format (Formats)](#/formats/format) and 33 more | | `filter` | [`FilterFor`](#type-FilterFor)`` | — | A filter in the column header: `"list"` offers the values that occur, `"range"` two bounds (numbers or points in time), a filter from `columnFilter` asks whatever it asks itself. Its condition stands in the table toolbar.
Shown in: [A label wider than its column (Width and pinning)](#/width-and-pinning/a-label-wider-than-its-column), [Share a column between tables (Presets)](#/presets/presets), [Choose from the values that occur (Filter)](#/filter/list-filter) and 11 more | | `group` | [`GroupFor`](#type-GroupFor)`` | — | Groups points in time by `"day"`, `"week"`, `"month"` or `"year"`.
Shown in: [Share a column between tables (Presets)](#/presets/presets), [Group dates by month (Grouping)](#/grouping/by-month) | | `edit` | [`EditFor`](#type-EditFor)`` | — | Edits the column's cells in place in grid mode: Enter, F2 or typing starts it, Enter or Tab commits, Escape cancels. The table applies nothing - the edit is reported through the table's `onCellEdit`. From `EditOptions`.
Shown in: [Share a column between tables (Presets)](#/presets/presets), [Combine grid mode with groups, pinning and many rows (Grid mode)](#/grid-mode/the-whole-grid), [Edit a comments column (Edits)](#/edits/comments-column) and 6 more | | `editOptions` | `readonly `[`Present`](#type-Present)`[]` | the values that occur | What `edit="select"` offers. From `EditOptions`.
Shown in: [Share a column between tables (Presets)](#/presets/presets), [Check an edit before it is reported (Edits)](#/edits/validated-edits), [Save a row on purpose (Edits)](#/edits/save-a-row-on-purpose) and 1 more | | `validate` | `(value: Z[K] \| null, row: Z) => string \| null \| undefined` | — | Checks a draft before it is reported: a message keeps the editor open and stands beneath the field, as in a `FormField`; nothing lets it through. From `EditOptions`.
Shown in: [Share a column between tables (Presets)](#/presets/presets), [Combine grid mode with groups, pinning and many rows (Grid mode)](#/grid-mode/the-whole-grid), [Check an edit before it is reported (Edits)](#/edits/validated-edits) and 3 more | | `children` | [`Presentation`](#type-Presentation)`` | — | How the value appears: `(value, row)`. Never called for an absent value. Required where the value has no text form.
Shown in: [Draw a value with other components (Formats)](#/formats/presentation), [Show absent values (Formats)](#/formats/absent-values), [Fit what does not fit (Formats)](#/formats/values-that-do-not-fit) and 9 more | Also every prop of `ColumnBase`, `ValuePaths` and `AggregateOptions`. ##### `ColumnBase` | Prop | Type | Default | Description | |---|---|---|---| | `label` *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export.
Shown in: [Read a value from a field](#/column/value-from-field), [Compute a value from the row](#/column/computed-value), [Let the value type decide](#/column/defaults) and 118 more | | `rowHeader` | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table.
Shown in: [Read a value from a field](#/column/value-from-field), [Compute a value from the row](#/column/computed-value), [Let the value type decide](#/column/defaults) and 117 more | | `numeric` | `boolean` | decided by the first value present | Right-aligned with tabular figures.
Shown in: [Share a column between tables (Presets)](#/presets/presets) | | `width` | `number` | the browser's | Initial width in pixels.
Shown in: [Fit long values (Formats)](#/formats/long-values), [Fit what does not fit (Formats)](#/formats/values-that-do-not-fit), [Let people set the width (Width and pinning)](#/width-and-pinning/width) and 15 more | | `resizable` | `boolean` | — | Shows the drag grip on the header cell.
Shown in: [Let people set the width (Width and pinning)](#/width-and-pinning/width), [Pin and size from controls of your own (Width and pinning)](#/width-and-pinning/pin-and-size-from-controls-of-your-own), [Share a column between tables (Presets)](#/presets/presets) and 1 more | | `pin` | [`Pin`](#type-Pin)
`"start" \| "end"` | — | Keeps the column in view while the table scrolls sideways: in a block before every other column, or after them. The user can change it in the column menu; the view carries the change. Pinned blocks take at most half of the width the table shows: on a narrower one the end block, then the start block scrolls with the rest, and sticks again when there is room.
Shown in: [Pin columns to both sides (Width and pinning)](#/width-and-pinning/pinned-both-sides), [Share a column between tables (Presets)](#/presets/presets), [Combine grid mode with groups, pinning and many rows (Grid mode)](#/grid-mode/the-whole-grid) and 1 more | | `sortable` | `boolean` | `true` when the value is text, a number, a point in time or a boolean | Whether the column can be sorted by.
Shown in: [Fit long values (Formats)](#/formats/long-values), [Fit what does not fit (Formats)](#/formats/values-that-do-not-fit), [Let people set the width (Width and pinning)](#/width-and-pinning/width) and 6 more | | `searchable` | `boolean` | `true` for text, `false` otherwise | Takes part in the search.
Shown in: [Share a column between tables (Presets)](#/presets/presets), [Leave a column out (Search)](#/search/leave-a-column-out), [An uneven organisation (Tree rows)](#/tree-rows/an-uneven-organisation) | | `groupable` | `boolean` | `true` when its value is text, a number, a point in time or a boolean, or it has a `groupValue` | Can the table be grouped by this column?
Shown in: [Share a column between tables (Presets)](#/presets/presets), [Switch grouping off (Grouping)](#/grouping/switched-off) | ##### `ValuePaths` | Prop | Type | Default | Description | |---|---|---|---| | `sortValue` | `(value: `[`Present`](#type-Present)`) => string \| number \| `[`Absent`](#type-Absent) | — | What is sorted by, when not by the value itself.
Shown in: [Share a column between tables (Presets)](#/presets/presets), [Sort and export a value that is an object (Sorting)](#/sorting/own-ordering), [Work through the alerts (Scenarios)](#/scenarios/work-through-the-alerts) | | `exportValue` | `(value: `[`Present`](#type-Present)`) => string \| number \| `[`Absent`](#type-Absent) | — | What stands in the export, when not the value itself.
Shown in: [Share a column between tables (Presets)](#/presets/presets), [Sort and export a value that is an object (Sorting)](#/sorting/own-ordering) | | `groupValue` | `(value: `[`Present`](#type-Present)`) => string \| number \| boolean \| Date \| `[`Absent`](#type-Absent) | — | What is grouped by, when not the value itself - a group header of a number, say. Rows whose group value is absent form a group of their own.
Shown in: [Share a column between tables (Presets)](#/presets/presets), [Group numbers in bands (Grouping)](#/grouping/in-bands) | ##### Types on this page ###### `FormatFor` What a column accepts as `format`: a number format for numbers, a date format for points in time, nothing for every other value. ```ts type FormatFor = [Present] extends [number] ? NumberFormat : [Present] extends [Date] ? DateFormat : never; ``` ###### `FilterFor` What a column accepts as `filter`: `"list"` for every value with a text form, `"range"` for numbers and points in time, a filter from `columnFilter` only where its value type fits. ```ts type FilterFor = | (IsDisplayable extends true ? "list" : never) | ([Present] extends [number] ? "range" : [Present] extends [Date] ? "range" : never) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the condition belongs to the filter; at the column only the value counts | ColumnFilter, any>; ``` ###### `GroupFor` `group` on a point in time: its rows are grouped by day, ISO week, month or year. ```ts type GroupFor = [Present] extends [Date] ? DatePeriod : never; ``` ###### `EditFor` What a column accepts as `edit`: the core field for its value - `"text"` for text, `"number"` for numbers, `"date"` for points in time, `"select"` for text and numbers - or an editor of one's own. ```ts type EditFor = | ([Present] extends [string] ? "text" | "select" : never) | ([Present] extends [number] ? "number" | "select" : never) | ([Present] extends [Date] ? "date" : never) | ((editor: CellEditorProps) => ReactNode); ``` ###### `Present` The value without the absence - that is what `children` gets. ```ts type Present = Exclude; ``` ###### `Presentation` What a column draws in a cell: the value present and its row. ```ts type Presentation = (value: Present, row: Z) => ReactNode; ``` ###### `Pin` Where a pinned column stands: in the block before or after the others. ```ts type Pin = "start" | "end"; ``` ###### `Absent` A value that is not there: `null` or `undefined`. ```ts type Absent = null | undefined; ``` ###### `NumberFormat` The names of the standard presentations of a number. ```ts type NumberFormat = "percent" | "count" | { decimals: number }; ``` ###### `DateFormat` The names of the standard presentations of a point in time. ```ts type DateFormat = "date" | "time" | "dateTime"; ``` ###### `IsDisplayable` `true` where the value present can appear as text without `children` (`Displayable`), `false` otherwise - what decides the filters and aggregates a column offers. ```ts type IsDisplayable = [Present] extends [never] ? false : [Present] extends [Displayable] ? true : false; ``` ###### `DatePeriod` The span a point in time is grouped by. ```ts type DatePeriod = "day" | "week" | "month" | "year"; ``` ###### `CellEditorProps` What an editor of one's own receives. The table holds the draft; Enter, Escape and Tab stay the table's, bubbling up from the field. | Prop | Type | Default | Description | |---|---|---|---| | `value` *required* | `W \| null` | — | The draft - the cell's value until the field changes it. | | `commit` *required* | `(value?: W \| null) => void` | — | Commits - the draft, or the value given. A picker commits on its pick. | | `row` *required* | `Z` | — | | | `invalid` *required* | `boolean` | — | The draft did not validate: the field shows it; the message stands beneath. | | `label` *required* | `string` | — | The field's name: what is edited, for which row. The `FormField` around the editor already names a core field by it. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | `onChange` *required* | `(value: W \| null) => void` | — | Changes the draft. | ###### `Displayable` What can appear as text without `children`. ```ts type Displayable = string | number | boolean | Date; ``` ### Formats How a value looks in its cell: a standard format, a presentation of its own, an absent value and a long one. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/formats/ A format or a presentation changes what the cell shows and nothing else: sorting, search, export and totals keep working on the value. `null`, `undefined` and `NaN` are an absent value: a muted dash, last in either sort direction, counted in no total. The presentation is never called for it. #### Examples ##### Pick a standard format `format` names a presentation from the provider's formats; it is typed against the value and keeps sorting, export and totals. ```tsx import { useTable } from "@umriss-ui/table"; interface Service { name: string; availability: number; requests: number; p95: number; deployed: Date; } const SERVICES: Service[] = [ { name: "Checkout", availability: 0.99921, requests: 1_284_310, p95: 412.37, deployed: new Date(2026, 2, 17, 9, 5) }, { name: "Billing", availability: 0.99958, requests: 402_118, p95: 238.9, deployed: new Date(2026, 2, 16, 16, 40) }, { name: "Search", availability: 0.99803, requests: 2_910_455, p95: 186.02, deployed: new Date(2026, 2, 12, 11, 15) }, ]; export default function Format() { const { Table, Column } = useTable(SERVICES, { rowKey: (s) => s.name }); return ( {/* A second column over the same field names its own id. */}
); } ``` ##### Draw a value with other components `children` receives the value, typed as the field, and the row; sorting still goes by the value, never by what is drawn. ```tsx import { Badge, Meter } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; interface Service { name: string; state: "Healthy" | "Degraded" | "Down"; budgetUsed: number; } const TONE = { Healthy: "success", Degraded: "warning", Down: "danger" } as const; const SERVICES: Service[] = [ { name: "Checkout", state: "Degraded", budgetUsed: 0.86 }, { name: "Billing", state: "Healthy", budgetUsed: 0.34 }, { name: "Webhooks", state: "Down", budgetUsed: 1 }, ]; export default function Presentation() { const { Table, Column } = useTable(SERVICES, { rowKey: (s) => s.name }); return ( {(state) => {state}} {(share, service) => ( 0.8 ? "warning" : "accent"} label={`Error budget used, ${service.name}`} showLabel /> )}
); } ``` ##### Show absent values `null`, `undefined` and `NaN` show a muted dash, sort last and count in no total: the average is over the three estimates given. ```tsx import { useTable } from "@umriss-ui/table"; interface WorkItem { id: string; name: string; estimate: number | null; } const WORK: WorkItem[] = [ { id: "w-101", name: "Sign-in with e-mail code", estimate: 18 }, { id: "w-102", name: "Profile page", estimate: null }, { id: "w-103", name: "Session handling", estimate: 12 }, { id: "w-105", name: "Change of address form", estimate: Number.NaN }, { id: "w-108", name: "Test plan for sign-in", estimate: 10 }, ]; export default function AbsentValues() { const { Table, Column } = useTable(WORK, { rowKey: (w) => w.id }); return ( {(hours) => `${hours} h`}
); } ``` ##### Fit long values A long value stays on its line and ends in an ellipsis; point at it, or make it the Active cell, and a tip shows it whole. A column with a `width` is exactly that wide, and a table wider than its place scrolls in its own frame. ```tsx import { useTable } from "@umriss-ui/table"; interface Shipment { id: string; customer: string; address: string; instructions: string; weight: number; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", customer: "Holloway Garden Supplies and Landscaping Cooperative", address: "Unit 14, Riverside Trading Estate, Old Weir Lane", instructions: "Deliver to the goods entrance at the back; ring twice, the bell in front is out of order.", weight: 18.4, }, { id: "FP-1004223", customer: "Oakridge Pharmacy", address: "2 Market Square", instructions: "", weight: 2.1 }, { id: "FP-1004236", customer: "Brixley Cycles", address: "88 Canal Street", instructions: "Leave with the neighbour at number 90 if closed.", weight: 23.7, }, ]; export default function LongValues() { const { Table, Column } = useTable(SHIPMENTS, { rowKey: (s) => s.id }); return (
); } ``` ##### Fit what does not fit Nothing in a cell wraps and no row grows. Text ends in an ellipsis, and a tip shows it whole; a component that does not fit is cut at the column's edge - give its column room, or show the whole in the row's detail. ```tsx import { Badge } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; interface Service { name: string; tags: readonly string[]; note: string; } const SERVICES: Service[] = [ { name: "Checkout", tags: ["payments", "eu-west", "critical", "pci"], note: "Card payments time out above 300 ms; the provider's status page says a partial outage." }, { name: "Billing", tags: ["payments", "eu-west"], note: "Invoices go out on the first working day." }, { name: "Webhooks", tags: ["integrations", "us-east", "beta"], note: "Retries back off up to an hour; a receiver that answers 410 is dropped." }, ]; export default function ValuesThatDoNotFit() { const { Table, Column } = useTable(SERVICES, { rowKey: (s) => s.name }); return ( {(tags) => tags.map((tag) => ( {tag} )) }
); } ``` #### Keyboard The keys of [First table](#/first-table/keyboard-first-table) apply here. #### Accessibility A screen reader reads what the cell shows, not the value behind it: a format is read as formatted, and the absent value's dash is spoken as “No value”. A presentation that draws a component brings that component's role along - a `Meter` is a meter - and names it per row: give it a label that says the row, “Error budget used, Checkout”, or every cell of the column sounds alike. #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | | [`id`](#/column/FieldColumn-id) | `string` | — | An id of one's own instead of the field name – for two columns over the same field. | | [`format`](#/column/FieldColumn-format) | [`FormatFor`](#/column/type-FormatFor)`` | — | A standard presentation by name: `"percent"`, `"count"` or `{ decimals }` for numbers, `"date"`, `"time"` or `"dateTime"` for points in time. Alignment, sorting, export and footer stay with the value. | | [`children`](#/column/FieldColumn-children) | [`Presentation`](#/column/type-Presentation)`` | — | How the value appears: `(value, row)`. Never called for an absent value. Required where the value has no text form. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | | [`width`](#/column/ColumnBase-width) | `number` | the browser's | Initial width in pixels. | | [`sortable`](#/column/ColumnBase-sortable) | `boolean` | `true` when the value is text, a number, a point in time or a boolean | Whether the column can be sorted by. | From `AggregateOptions` — the full table stands on [Aggregate](#/aggregate/type-AggregateOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`aggregate`](#/aggregate/AggregateOptions-aggregate) | [`AggregateFor`](#/aggregate/type-AggregateFor)`` | — | What the column's values come to: in the footer over the filtered set, in a group's header over its rows. `"sum"`, `"avg"` for numbers; `"min"`, `"max"` for numbers and points in time; `"range"` for points in time; `"count"`, `"distinct"` for every value with a text form - or a function of one's own. Always from the values, never from other aggregates; absent values count towards nothing. | ### Width and pinning How wide a column stands, which columns stay in view while the table scrolls sideways (frozen or sticky columns), and a header that stays while it scrolls down. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/width-and-pinning/ #### Examples ##### Let people set the width `width` is the starting width, `resizable` adds a grip: drag it, double-click it to fit the content, or press Alt and an arrow. ```tsx import { useTable } from "@umriss-ui/table"; interface Shipment { id: string; customer: string; note: string; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", customer: "Holloway Garden Supplies", note: "Goods entrance at the back, ring twice" }, { id: "FP-1004223", customer: "Oakridge Pharmacy", note: "Cold chain, hand over in person" }, { id: "FP-1004236", customer: "Brixley Cycles", note: "Leave with the neighbour at number 90 if the shop is closed" }, ]; export default function Width() { const { Table, Column } = useTable(SHIPMENTS, { rowKey: (s) => s.id }); return (
); } ``` ##### Pin columns to both sides `pin="start"` or `"end"` keeps a column at that edge while the rest scrolls; people pin and unpin in the column menu too. ```tsx import { useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import type { LimitSet } from "@umriss-ui/core"; import { ColumnMenu, Toolbar, useTable } from "@umriss-ui/table"; /* Data from the plant world, written out here so the example runs on its own. */ const STATIONS = [ { id: "saw", label: "Saw 1" }, { id: "lathe-1", label: "Lathe 1" }, { id: "lathe-2", label: "Lathe 2" }, { id: "mill", label: "Mill" }, { id: "press", label: "Press 2" }, { id: "paint", label: "Paint shop" }, { id: "qa", label: "Inspection" }, ] as const; /* The minimum width comes from the application's stylesheet: a table over the full width shares out the space it has, and nothing would scroll. */ const STYLE = ` .hourly-output table { min-width: 1500px; } `; const HOURS = ["06", "07", "08", "09", "10", "11", "12", "13", "14", "15", "16", "17"]; interface Machine { name: string; area: string; output: number[]; temperature: number | null; } const TEMPERATURE: LimitSet = { target: 60, limits: [ { value: 72, side: "upper", severity: "warning" }, { value: 80, side: "upper", severity: "alarm" }, ], }; const AREAS = ["Cutting", "Turning", "Forming", "Finishing"]; const ROWS: Machine[] = STATIONS.map(({ label: name }, i) => ({ name, area: AREAS[Math.floor(i / 2)]!, output: HOURS.map((_, h) => 30 + ((i * 11 + h * 7) % 29)), temperature: i === 5 ? null : 52 + ((i * 9) % 31), })); export default function PinnedBothSides() { const { Table, Column, VerdictColumn, RowActions, Action } = useTable(ROWS, { rowKey: (m) => m.name }); const [last, setLast] = useState("Nothing opened yet"); return ( {HOURS.map((hour, h) => ( m.output[h]} aggregate="sum" width={80} /> ))} setLast(`${m.name} opened`)}>Open
{last}
); } ``` ##### Keep the header and the names in view `stickyHeader` holds the header row while the rows scroll, `stickyRowHeader` the name column while the days scroll sideways. ```tsx import { Text } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; /* `width` is an initial width: a table over the full width shares out the space it has, so the minimum width that makes it scroll comes from the application's stylesheet. */ const STYLE = ` .week-table table { min-width: 1400px; } `; const DAYS = ["Mon 16", "Tue 17", "Wed 18", "Thu 19", "Fri 20", "Mon 23", "Tue 24", "Wed 25", "Thu 26", "Fri 27"]; interface Person { id: string; name: string; team: string; role: string; hours: number[]; } const PEOPLE: Person[] = [ ["Maya Lindgren", "Web", "Product manager"], ["Arjun Mehta", "Web", "Developer"], ["Chloe Durand", "Web", "Developer"], ["Noah Fischer", "Web", "Designer"], ["Eva Novak", "Web", "QA engineer"], ["Luis Moreno", "Apps", "Product manager"], ["Hana Sato", "Apps", "Developer"], ["Kofi Mensah", "Apps", "Developer"], ["Freya Olsen", "Apps", "Designer"], ["David Kowalski", "Apps", "QA engineer"], ].map(([name, team, role], i) => ({ id: `p${i + 1}`, name: name!, team: team!, role: role!, hours: DAYS.map((_, d) => 4 + ((i * 5 + d * 3) % 5)), })); export default function StickyParts() { const { Table, Column, RowDetail } = useTable(PEOPLE, { rowKey: (p) => p.id }); return ( <> {DAYS.map((day, d) => ( p.hours[d]} width={96} /> ))} {(p) => ( {p.name}, {p.role}, team {p.team} )}
); } ``` ##### Pin and size from controls of your own `t.setPin` pins a column or, with `null`, unpins it; `t.setWidth` sets a width and `undefined` takes it back. `t.pinned` and `t.widths` say how the columns stand, a dragged grip included. ```tsx import { Button, Stack, Switch, Text } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; /* The minimum width comes from the application's stylesheet, so that the table scrolls sideways and a pinned column has something to hold against. */ const STYLE = ` .delivery-notes table { min-width: 1100px; } `; interface Shipment { id: string; customer: string; town: string; window: string; weight: number; note: string; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", customer: "Holloway Garden Supplies", town: "Ashcombe", window: "08:00–10:00", weight: 23, note: "Goods entrance at the back, ring twice" }, { id: "FP-1004223", customer: "Oakridge Pharmacy", town: "Oakridge", window: "08:00–10:00", weight: 4, note: "Cold chain, hand over in person" }, { id: "FP-1004236", customer: "Brixley Cycles", town: "Brixley", window: "10:00–12:00", weight: 14, note: "Leave with the neighbour at number 90 if the shop is closed" }, { id: "FP-1004249", customer: "Tamsin's Bakery", town: "Pellham", window: "06:00–08:00", weight: 31, note: "Before opening, side door" }, ]; export default function PinAndSizeFromControls() { const t = useTable(SHIPMENTS, { rowKey: (s) => s.id }); const { Table, Column } = t; const wide = t.widths.note === 420; return ( t.setPin("customer", event.target.checked ? "start" : null)} /> Note: {t.widths.note} px
); } ``` ##### A label wider than its column A column with a `width` is that wide, whatever its label says. The label ends in an ellipsis; the sort arrow and the filter stay. ```tsx import { useTable } from "@umriss-ui/table"; interface Shipment { id: string; weight: number; carrier: string; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", weight: 12.4, carrier: "Northline" }, { id: "FP-1004223", weight: 3.1, carrier: "Eastway" }, { id: "FP-1004236", weight: 48, carrier: "Northline" }, ]; export default function ALabelWiderThanItsColumn() { const { Table, Column } = useTable(SHIPMENTS, { rowKey: (s) => s.id, defaultSort: { column: "weight", direction: "desc" } }); return (
); } ``` #### Keyboard | Key | Action | |---|---| | `Alt + → / Alt + ←` | On a resizable header: wider or narrower. Alt + Shift + → and Alt + Shift + ← take larger steps. | | `Alt + Home` | On a resizable header: fits the width to the content. | #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`selectable`](#/first-table/TableProps-selectable) | `boolean` | — | Adds the selection column. "All" means: the filtered set. | | [`stickyHeader`](#/first-table/TableProps-stickyHeader) | `boolean` | — | Keeps the header row visible while scrolling. | | [`stickyRowHeader`](#/first-table/TableProps-stickyRowHeader) | `boolean` | — | Keeps the row header column visible while scrolling sideways - the same as `pin="start"` on the row header. | | [`maxHeight`](#/first-table/TableProps-maxHeight) | `string` | — | Height of the scroll area, e.g. "480px". A virtualised table needs it. | | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | ###### Styling | Prop | Type | Default | Description | |---|---|---|---| | [`className`](#/first-table/TableProps-className) | `string` | — | Classes on the outer frame of the table. | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | | [`defaultSort`](#/first-table/TableOptions-defaultSort) | [`SortLevel`](#/first-table/type-SortLevel)` \| readonly `[`SortLevel`](#/first-table/type-SortLevel)`[] \| null` | `null` | The application's sort; it is the default that `view` leaves out. | From `TableSnapshot` — the full table stands on [First table](#/first-table/type-TableSnapshot). | Prop | Type | Default | Description | |---|---|---|---| | [`pinned`](#/first-table/TableSnapshot-pinned) *required* | `Readonly>` | — | The pinned columns by id - the declared ones until the user chooses. | | [`setPin`](#/first-table/TableSnapshot-setPin) *required* | `(column: string, pin: `[`Pin`](#/first-table/type-Pin)` \| null) => void` | — | Pins a column to the start or the end, or unpins it with `null`. | | [`widths`](#/first-table/TableSnapshot-widths) *required* | `Readonly>` | — | The widths in pixels: those of the columns, and over them the dragged ones. | | [`setWidth`](#/first-table/TableSnapshot-setWidth) *required* | `(column: string, width: number \| undefined) => void` | — | Sets the width of a column; `undefined` takes it back. | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | | [`filter`](#/column/FieldColumn-filter) | [`FilterFor`](#/column/type-FilterFor)`` | — | A filter in the column header: `"list"` offers the values that occur, `"range"` two bounds (numbers or points in time), a filter from `columnFilter` asks whatever it asks itself. Its condition stands in the table toolbar. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | | [`width`](#/column/ColumnBase-width) | `number` | the browser's | Initial width in pixels. | | [`resizable`](#/column/ColumnBase-resizable) | `boolean` | — | Shows the drag grip on the header cell. | | [`pin`](#/column/ColumnBase-pin) | [`Pin`](#/column/type-Pin)
`"start" \| "end"` | — | Keeps the column in view while the table scrolls sideways: in a block before every other column, or after them. The user can change it in the column menu; the view carries the change. Pinned blocks take at most half of the width the table shows: on a narrower one the end block, then the start block scrolls with the rest, and sticks again when there is room. | | [`sortable`](#/column/ColumnBase-sortable) | `boolean` | `true` when the value is text, a number, a point in time or a boolean | Whether the column can be sorted by. | From `RowDetailProps` — the full table stands on [RowDetail](#/rowdetail/type-RowDetailProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/rowdetail/RowDetailProps-children) *required* | `(row: Z) => ReactNode` | — | What stands underneath an expanded row. | From `RowActionsProps` — the full table stands on [RowActions](#/rowactions/type-RowActionsProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/rowactions/RowActionsProps-children) *required* | `ReactNode` | — | The row's `Action`s. | From `ActionProps` — the full table stands on [RowActions](#/rowactions/type-ActionProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/rowactions/ActionProps-children) *required* | `string` | — | The label – text, because it is read out together with the row's name. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onSelect`](#/rowactions/ActionProps-onSelect) *required* | `((row: Z) => void) \| ((rows: readonly Z[]) => void)` | — | Gets the row the action was triggered on. With `bulk` a list: at the row one made from it, in the table toolbar the selection. | From `ToolbarProps` — the full table stands on [Toolbar](#/toolbar/type-ToolbarProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/toolbar/ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. | From `VerdictBase` — the full table stands on [VerdictColumn](#/verdictcolumn/type-VerdictBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/verdictcolumn/VerdictBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`limits`](#/verdictcolumn/VerdictBase-limits) *required* | [`LimitSet`](#/verdictcolumn/type-LimitSet) | — | The limit set every value is read against. | | [`width`](#/verdictcolumn/VerdictBase-width) | `number` | the browser's | Initial width in pixels. | | [`pin`](#/verdictcolumn/VerdictBase-pin) | [`Pin`](#/verdictcolumn/type-Pin)
`"start" \| "end"` | — | Keeps the column in view while the table scrolls sideways, before or after every other column. | | [`aggregate`](#/verdictcolumn/VerdictBase-aggregate) | `"worst"` | — | `"worst"`: the worst verdict among the rows – in the footer and in a group's header. | ### Presets A column written once and used in many tables: as a preset spread into a column, or as a component that brings its own presentation. ```ts import { column } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/presets/ #### Examples ##### Share a column between tables `column

()` makes a preset bound to a property, not to a row kind; spread it into any table whose rows have that field. ```tsx import { Stack } from "@umriss-ui/core"; import { column, useTable } from "@umriss-ui/table"; /* The compiler checks the spread: a row without a numeric `amount` is refused. With more than one field, name the field too - `column<{ amount: number; id: string }, "amount">(…)`. */ const amount = column<{ amount: number }>({ value: "amount", label: "Amount (€)", format: { decimals: 2 }, aggregate: "sum" }); interface Invoice { id: string; amount: number; } interface Budget { costCentre: string; amount: number; } const INVOICES: Invoice[] = [ { id: "INV-26-0318", amount: 1951.24 }, { id: "INV-26-0309", amount: 7288 }, ]; const BUDGETS: Budget[] = [ { costCentre: "Marketing", amount: 68_000 }, { costCentre: "IT", amount: 83_000 }, ]; function Invoices() { const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id }); return (
); } function Budgets() { const { Table, Column } = useTable(BUDGETS, { rowKey: (b) => b.costCentre }); return (
); } export default function Presets() { return ( ); } ``` ##### Wrap a column in a component A column that brings its presentation becomes a component; it takes the table as `of`, typed with a row kind, and uses its `Column`. ```tsx import { Badge } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; import type { Table } from "@umriss-ui/table"; /* A wrapper generic over its rows does not compile - TypeScript does not resolve the overloads against an open row type - and one without `of` compiles but is unchecked. */ interface Incident { id: string; severity: "SEV1" | "SEV2" | "SEV3"; } const TONE = { SEV1: "danger", SEV2: "warning", SEV3: "neutral" } as const; const INCIDENTS: Incident[] = [ { id: "INC-1048", severity: "SEV1" }, { id: "INC-1047", severity: "SEV3" }, { id: "INC-1046", severity: "SEV2" }, ]; function SeverityColumn({ of }: { of: Table }) { return ( {(severity) => {severity}} ); } export default function Wrapper() { const t = useTable(INCIDENTS, { rowKey: (i) => i.id }); const { Table, Column } = t; return (
); } ``` #### Keyboard The keys of [First table](#/first-table/keyboard-first-table) apply here. #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | | [`id`](#/column/FieldColumn-id) | `string` | — | An id of one's own instead of the field name – for two columns over the same field. | | [`format`](#/column/FieldColumn-format) | [`FormatFor`](#/column/type-FormatFor)`` | — | A standard presentation by name: `"percent"`, `"count"` or `{ decimals }` for numbers, `"date"`, `"time"` or `"dateTime"` for points in time. Alignment, sorting, export and footer stay with the value. | | [`filter`](#/column/FieldColumn-filter) | [`FilterFor`](#/column/type-FilterFor)`` | — | A filter in the column header: `"list"` offers the values that occur, `"range"` two bounds (numbers or points in time), a filter from `columnFilter` asks whatever it asks itself. Its condition stands in the table toolbar. | | [`group`](#/column/FieldColumn-group) | [`GroupFor`](#/column/type-GroupFor)`` | — | Groups points in time by `"day"`, `"week"`, `"month"` or `"year"`. | | [`edit`](#/column/FieldColumn-edit) | [`EditFor`](#/column/type-EditFor)`` | — | Edits the column's cells in place in grid mode: Enter, F2 or typing starts it, Enter or Tab commits, Escape cancels. The table applies nothing - the edit is reported through the table's `onCellEdit`. From `EditOptions`. | | [`editOptions`](#/column/FieldColumn-editOptions) | `readonly `[`Present`](#/column/type-Present)`[]` | the values that occur | What `edit="select"` offers. From `EditOptions`. | | [`validate`](#/column/FieldColumn-validate) | `(value: Z[K] \| null, row: Z) => string \| null \| undefined` | — | Checks a draft before it is reported: a message keeps the editor open and stands beneath the field, as in a `FormField`; nothing lets it through. From `EditOptions`. | | [`children`](#/column/FieldColumn-children) | [`Presentation`](#/column/type-Presentation)`` | — | How the value appears: `(value, row)`. Never called for an absent value. Required where the value has no text form. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | | [`numeric`](#/column/ColumnBase-numeric) | `boolean` | decided by the first value present | Right-aligned with tabular figures. | | [`width`](#/column/ColumnBase-width) | `number` | the browser's | Initial width in pixels. | | [`resizable`](#/column/ColumnBase-resizable) | `boolean` | — | Shows the drag grip on the header cell. | | [`pin`](#/column/ColumnBase-pin) | [`Pin`](#/column/type-Pin)
`"start" \| "end"` | — | Keeps the column in view while the table scrolls sideways: in a block before every other column, or after them. The user can change it in the column menu; the view carries the change. Pinned blocks take at most half of the width the table shows: on a narrower one the end block, then the start block scrolls with the rest, and sticks again when there is room. | | [`sortable`](#/column/ColumnBase-sortable) | `boolean` | `true` when the value is text, a number, a point in time or a boolean | Whether the column can be sorted by. | | [`searchable`](#/column/ColumnBase-searchable) | `boolean` | `true` for text, `false` otherwise | Takes part in the search. | | [`groupable`](#/column/ColumnBase-groupable) | `boolean` | `true` when its value is text, a number, a point in time or a boolean, or it has a `groupValue` | Can the table be grouped by this column? | From `ValuePaths` — the full table stands on [Column](#/column/type-ValuePaths). | Prop | Type | Default | Description | |---|---|---|---| | [`sortValue`](#/column/ValuePaths-sortValue) | `(value: `[`Present`](#/column/type-Present)`) => string \| number \| `[`Absent`](#/column/type-Absent) | — | What is sorted by, when not by the value itself. | | [`exportValue`](#/column/ValuePaths-exportValue) | `(value: `[`Present`](#/column/type-Present)`) => string \| number \| `[`Absent`](#/column/type-Absent) | — | What stands in the export, when not the value itself. | | [`groupValue`](#/column/ValuePaths-groupValue) | `(value: `[`Present`](#/column/type-Present)`) => string \| number \| boolean \| Date \| `[`Absent`](#/column/type-Absent) | — | What is grouped by, when not the value itself - a group header of a number, say. Rows whose group value is absent form a group of their own. | From `AggregateOptions` — the full table stands on [Aggregate](#/aggregate/type-AggregateOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`aggregate`](#/aggregate/AggregateOptions-aggregate) | [`AggregateFor`](#/aggregate/type-AggregateFor)`` | — | What the column's values come to: in the footer over the filtered set, in a group's header over its rows. `"sum"`, `"avg"` for numbers; `"min"`, `"max"` for numbers and points in time; `"range"` for points in time; `"count"`, `"distinct"` for every value with a text form - or a function of one's own. Always from the values, never from other aggregates; absent values count towards nothing. | | [`share`](#/aggregate/AggregateOptions-share) | `boolean` | — | The share bar in a group's header – the group's share of the filtered set's sum. It stands under the first column with a sum, one per header; `false` passes it on to the next. | ## Finding rows How a user gets from many rows to the ones that matter. ### Sorting Order the rows by a column, and by further columns where the first ties. Reach for a sort order of your own where the value is an object. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/sorting/ #### Examples ##### Sort by several columns A click on a header sorts, Shift and click adds a level; `defaultSort` takes the levels the table starts with. ```tsx import { useTable } from "@umriss-ui/table"; interface Incident { id: string; title: string; severity: "SEV1" | "SEV2" | "SEV3"; opened: Date; assignee: string; } const at = (day: number, hours: number, minutes: number) => new Date(2026, 2, day, hours, minutes); const INCIDENTS: Incident[] = [ { id: "INC-1048", title: "Checkout slow, card payments time out", severity: "SEV1", opened: at(17, 9, 42), assignee: "Jonas Keller" }, { id: "INC-1047", title: "Webhook deliveries delayed", severity: "SEV3", opened: at(17, 7, 15), assignee: "Ines Duarte" }, { id: "INC-1046", title: "Thumbnails missing for new uploads", severity: "SEV2", opened: at(16, 22, 5), assignee: "Tomasz Nowak" }, { id: "INC-1045", title: "Sign-in codes arrive late", severity: "SEV2", opened: at(16, 14, 20), assignee: "Ada Mwangi" }, { id: "INC-1043", title: "Search returns no results for some regions", severity: "SEV1", opened: at(14, 18, 30), assignee: "Leila Haddad" }, { id: "INC-1042", title: "Duplicate reminder e-mails", severity: "SEV3", opened: at(13, 8, 45), assignee: "Sam Okafor" }, ]; export default function SortBySeveralColumns() { const { Table, Column } = useTable(INCIDENTS, { rowKey: (i) => i.id, defaultSort: [ { column: "severity", direction: "asc" }, { column: "opened", direction: "desc" }, ], }); return (
); } ``` ##### Sort and export a value that is an object A value without an order or a text form needs `children`; `sortValue` gives what to sort by, `exportValue` the text for the file. ```tsx import { useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { Export, Toolbar, useTable } from "@umriss-ui/table"; interface Parcel { id: string; size: { length: number; width: number; height: number }; } const PARCELS: Parcel[] = [ { id: "FP-1004210", size: { length: 60, width: 40, height: 40 } }, { id: "FP-1004223", size: { length: 30, width: 20, height: 10 } }, { id: "FP-1004236", size: { length: 120, width: 60, height: 20 } }, ]; export default function OwnOrdering() { const [csv, setCsv] = useState("Not exported yet."); const { Table, Column } = useTable(PARCELS, { rowKey: (p) => p.id }); return ( s.length * s.width * s.height} exportValue={(s) => `${s.length} x ${s.width} x ${s.height}`} > {(s) => `${s.length} × ${s.width} × ${s.height}`}
{csv}
); } ``` #### Keyboard | Key | Action | |---|---| | `Shift + click` | On a header: adds the column as a further sort level. | #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | | [`defaultSort`](#/first-table/TableOptions-defaultSort) | [`SortLevel`](#/first-table/type-SortLevel)` \| readonly `[`SortLevel`](#/first-table/type-SortLevel)`[] \| null` | `null` | The application's sort; it is the default that `view` leaves out. | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | | [`format`](#/column/FieldColumn-format) | [`FormatFor`](#/column/type-FormatFor)`` | — | A standard presentation by name: `"percent"`, `"count"` or `{ decimals }` for numbers, `"date"`, `"time"` or `"dateTime"` for points in time. Alignment, sorting, export and footer stay with the value. | | [`children`](#/column/FieldColumn-children) | [`Presentation`](#/column/type-Presentation)`` | — | How the value appears: `(value, row)`. Never called for an absent value. Required where the value has no text form. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | From `ValuePaths` — the full table stands on [Column](#/column/type-ValuePaths). | Prop | Type | Default | Description | |---|---|---|---| | [`sortValue`](#/column/ValuePaths-sortValue) | `(value: `[`Present`](#/column/type-Present)`) => string \| number \| `[`Absent`](#/column/type-Absent) | — | What is sorted by, when not by the value itself. | | [`exportValue`](#/column/ValuePaths-exportValue) | `(value: `[`Present`](#/column/type-Present)`) => string \| number \| `[`Absent`](#/column/type-Absent) | — | What stands in the export, when not the value itself. | From `ToolbarProps` — the full table stands on [Toolbar](#/toolbar/type-ToolbarProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/toolbar/ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. | From `ExportProps` — the full table stands on [Export](#/export/type-ExportProps). ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onExport`](#/export/ExportProps-onExport) | `(text: string) => void` | — | Gets the text instead of triggering a file. | #### Known limits - In [server mode](#/server-mode) the table sorts nothing itself; the sort goes to the server in the request. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### Search A text field that narrows the table to the rows containing what the user types (also called a quick filter or global filter). Put it in the toolbar, or anywhere on the page. ```ts import { Search } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/search/ It searches the columns whose value is text, and it searches the value, not its presentation; `searchable` on a column adds or removes one. A search term goes back to page one and counts as a restriction – the toolbar shows the matches and “Reset” – but it is not listed as a condition, since the field already shows it. #### Examples ##### Find a person Put a `Search` into the toolbar; it looks through every text column, and the toolbar shows how many rows still match. ```tsx import { Search, Toolbar, useTable } from "@umriss-ui/table"; interface Person { name: string; role: string; team: string; capacity: number; } const PEOPLE: Person[] = [ { name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 }, { name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 }, { name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 }, { name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 }, { name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 }, ]; export default function FindAPerson() { const { Table, Column } = useTable(PEOPLE, { rowKey: (p) => p.name }); return (
); } ``` ##### Leave a column out Set `searchable={false}` on a text column the search should skip – here the team, so “Web” finds the web developer and not the whole team. ```tsx import { Search, Toolbar, useTable } from "@umriss-ui/table"; interface Person { name: string; role: string; team: string; } const PEOPLE: Person[] = [ { name: "Maya Lindgren", role: "Product manager", team: "Web" }, { name: "Chloe Durand", role: "Web developer", team: "Web" }, { name: "Kofi Mensah", role: "App developer", team: "Apps" }, { name: "David Kowalski", role: "QA engineer", team: "Apps" }, ]; export default function LeaveAColumnOut() { const { Table, Column } = useTable(PEOPLE, { rowKey: (p) => p.name }); return (
); } ``` ##### Search from a card header Pass the table as `of` and the search can stand anywhere; the match count and “Reset” still appear at the table. ```tsx import { Card, CardBody, CardHeader } from "@umriss-ui/core"; import { Search, useTable } from "@umriss-ui/table"; interface Person { name: string; role: string; } const PEOPLE: Person[] = [ { name: "Luis Moreno", role: "Product manager" }, { name: "Hana Sato", role: "Developer" }, { name: "Freya Olsen", role: "Designer" }, ]; export default function SearchFromACardHeader() { const t = useTable(PEOPLE, { rowKey: (p) => p.name }); const { Table, Column } = t; return ( } />
); } ``` ##### Search takes the line `` takes the rest of the toolbar's line; the column menu and the export stand at its end. ```tsx import { ColumnMenu, Export, Search, Toolbar, useTable } from "@umriss-ui/table"; interface Person { name: string; role: string; city: string; } const PEOPLE: Person[] = [ { name: "Luis Moreno", role: "Product manager", city: "Lisbon" }, { name: "Hana Sato", role: "Developer", city: "Osaka" }, { name: "Freya Olsen", role: "Designer", city: "Bergen" }, ]; export default function SearchTakesTheLine() { const { Table, Column } = useTable(PEOPLE, { rowKey: (p) => p.name }); return (
); } ``` #### When to use something else - Restrict one column to some of its values or to a range → Filter #### Keyboard The keys of [First table](#/first-table/keyboard-first-table) and [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input) apply here. #### API ##### `SearchProps` | Prop | Type | Default | Description | |---|---|---|---| | `placeholder` | `string` | the wording's "Search …" | The placeholder in the field.
Shown in: [Find a person](#/search/find-a-person), [Leave a column out](#/search/leave-a-column-out), [Search from a card header](#/search/search-from-a-card-header) and 23 more | | `size` | [`ToolbarSize`](#type-ToolbarSize)
`"sm" \| "md"` | the size of the toolbar it stands in, `"sm"` outside one | The size of the part: `sm` or `md`.
Shown in: [Parts outside a toolbar (Toolbar)](#/toolbar/parts-outside-a-toolbar) | | `grow` | `boolean` | `false` | Take the rest of the toolbar's line: the parts after the search - column menu, export - stand at its end.
Shown in: [Search takes the line](#/search/search-takes-the-line) | | `of` | [`TableRef`](#type-TableRef) | — | The table, when the part does not stand inside it: the return value of `useTable`.
Shown in: [Search from a card header](#/search/search-from-a-card-header), [Place it outside the table (Toolbar)](#/toolbar/place-it-outside), [Parts outside a toolbar (Toolbar)](#/toolbar/parts-outside-a-toolbar) and 1 more | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | `aria-label` | `string` | the wording's "Search table" | Accessible name.
Shown in: [Search from a card header](#/search/search-from-a-card-header) | ###### Styling | Prop | Type | Default | Description | |---|---|---|---| | `className` | `string` | — | Classes on the field. | ##### Types on this page ###### `ToolbarSize` The size of a toolbar's controls. ```ts type ToolbarSize = "sm" | "md"; ``` ###### `TableRef` What a part without a row type needs from a table – for `of`. ```ts type TableRef = Omit, "setFilter" | "conditionOf"> & { readonly Table: unknown; /** Without a row kind: any id, any condition. */ setFilter(column: string, condition: unknown): void; }; ``` #### Known limits - In [server mode](#/server-mode) the table searches nothing itself; the term goes to the server in the request. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### 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. ```ts import { columnFilter, rowFilter } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/filter/ 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. ```tsx 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 (
); } ``` ##### Keep values between two bounds `filter="range"` gives numbers and dates two inclusive bounds, either left open; date bounds are whole calendar days. ```tsx 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 (
); } ``` ##### Write the smallest filter of your own [`columnFilter`](#/api/columnFilter) takes `matches`, `describe` and the `Input` for the panel; the header funnel, the panel and the condition come from the table. ```tsx 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({ matches: (due) => due.getTime() < TODAY.getTime(), Input: ({ condition, setCondition }) => ( 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 (
); } ``` ##### Offer conditions of your own A filter from `columnFilter` fits every number column of every table; an absent value matches none of its conditions. ```tsx 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({ matches: (hours, load) => (load === "part-time" ? hours < 32 : hours >= 32), Input: ({ condition, setCondition, column }) => ( 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 (
); } ``` ##### 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. ```tsx 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 ( {QUICK_FILTERS.map((status) => { const active = JSON.stringify(t.filter.status) === JSON.stringify([status]); return ( ); })} {(status) => {status}}
What the application would keep {JSON.stringify(t.view)}
); } ``` ##### 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. ```tsx 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 ( t.setFilter(tours, next.length > 0 ? next : null)} />
); } ``` ##### 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. ```tsx 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(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 ( aria-label="Status" placeholder="Every status" options={STATUSES} value={t.conditionOf(statuses) ?? []} onChange={(next) => t.setFilter(statuses, next.length > 0 ? next : null)} />
); } ``` ##### 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. ```tsx 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 ( set(e.target.checked, condition?.failed ?? false)} /> set(true, e.target.checked)} />
); } ``` ##### 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. ```tsx import { RadioGroup } from "@umriss-ui/core"; import { columnFilter, useTable } from "@umriss-ui/table"; const atLeast = columnFilter({ matches: (hours, least) => hours >= least, Input: ({ condition, setCondition, values, column }) => ( ({ 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 (
); } ``` #### When to use something else - Free text across the columns → Search - Rows a user may not see at all, invisibly → Pre-filter #### Keyboard The keys of [First table](#/first-table/keyboard-first-table), [Popover](https://romanhaendler.github.io/umriss-ui/core/popover/#keyboard-popover), [Checkbox](https://romanhaendler.github.io/umriss-ui/core/checkbox/#keyboard-checkbox), [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input), [MultiSelect](https://romanhaendler.github.io/umriss-ui/core/multiselect/#keyboard-multiselect), [Select](https://romanhaendler.github.io/umriss-ui/core/select/#keyboard-select) and [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button) apply here. #### API ##### `ColumnFilter` | Prop | Type | Default | Description | |---|---|---|---| | `matches` *required* | `(value: `[`Present`](#type-Present)`, 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](#/filter/own-filter-minimal), [Offer conditions of your own](#/filter/own-filter), [Offer the values that occur](#/filter/offer-the-values-that-occur) | | `Input` *required* | `ComponentType<`[`FilterInputProps`](#type-FilterInputProps)`>` | — | 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](#/filter/own-filter-minimal), [Offer conditions of your own](#/filter/own-filter), [Offer the values that occur](#/filter/offer-the-values-that-occur) | | `describe` *required* | `(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](#/filter/own-filter-minimal), [Offer conditions of your own](#/filter/own-filter), [Offer the values that occur](#/filter/offer-the-values-that-occur) | ##### `FilterInputProps` | Prop | Type | Default | Description | |---|---|---|---| | `condition` *required* | `B \| null` | — | The condition that holds right now; `null` as long as the filter requires nothing.
Shown in: [Write the smallest filter of your own](#/filter/own-filter-minimal), [Offer conditions of your own](#/filter/own-filter), [Offer the values that occur](#/filter/offer-the-values-that-occur) | | `setCondition` *required* | `(condition: B \| null) => void` | — | Sets the condition; `null` lifts it.
Shown in: [Write the smallest filter of your own](#/filter/own-filter-minimal), [Offer conditions of your own](#/filter/own-filter), [Offer the values that occur](#/filter/offer-the-values-that-occur) | | `values` *required* | `readonly `[`Present`](#type-Present)`[]` | — | 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](#/filter/offer-the-values-that-occur) | | `column` *required* | `{ id: string; label: string }` | — | The column whose filter this is.
Shown in: [Offer conditions of your own](#/filter/own-filter), [Offer the values that occur](#/filter/offer-the-values-that-occur) | ##### `RowFilter` | Prop | Type | Default | Description | |---|---|---|---| | `id` *required* | `string` | — | 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](#/filter/a-field-no-column-shows), [Rows from a request](#/filter/rows-from-a-request), [Ask a question over several fields](#/filter/a-question-over-several-fields) and 3 more | | `label` *required* | `string` | — | 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](#/filter/a-field-no-column-shows), [Rows from a request](#/filter/rows-from-a-request), [Ask a question over several fields](#/filter/a-question-over-several-fields) and 3 more | | `matches` *required* | `(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](#/filter/a-field-no-column-shows), [Rows from a request](#/filter/rows-from-a-request), [Ask a question over several fields](#/filter/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](#/filter/a-question-over-several-fields), [A dispatcher's bar (Toolbar controls)](#/toolbar-controls/a-dispatcher-s-bar) | ##### Types on this page ###### `Present` The value without the absence - that is what `children` gets. ```ts type Present = Exclude; ``` ###### `Absent` A value that is not there: `null` or `undefined`. ```ts type Absent = null | undefined; ``` #### Known limits - No text filter per column: the search covers text. - In [server mode](#/server-mode) a list filter offers what the application names; the table cannot count values it does not hold. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### Pre-filter Decide which rows a table has at all – by permission, by site, by tenant. The user never sees it, cannot lift it, and it is not part of the view. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/pre-filter/ #### Examples ##### Restrict which rows a table has `preFilter` decides the rows at all, here by depot, elsewhere by permission; it is invisible, never reset, and not in the view. ```tsx import { useState } from "react"; import { Select, Stack } from "@umriss-ui/core"; import { Pagination, Search, Toolbar, useTable } from "@umriss-ui/table"; interface Shipment { id: string; depot: string; tour: string; customer: string; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", depot: "North depot", tour: "T-01", customer: "Holloway Garden Supplies" }, { id: "FP-1004223", depot: "Riverside depot", tour: "T-04", customer: "Oakridge Pharmacy" }, { id: "FP-1004236", depot: "North depot", tour: "T-02", customer: "Brixley Cycles" }, { id: "FP-1004249", depot: "Riverside depot", tour: "T-05", customer: "Tamsin's Bakery" }, { id: "FP-1004262", depot: "North depot", tour: "T-01", customer: "Pellham Hardware" }, { id: "FP-1004275", depot: "Riverside depot", tour: "T-04", customer: "Greywick Studio" }, { id: "FP-1004288", depot: "North depot", tour: "T-03", customer: "Juniper Lane Florist" }, ]; const DEPOTS = ["North depot", "Riverside depot", "East Gate depot"]; export default function PreFilter() { const [depot, setDepot] = useState("North depot"); const { Table, Column } = useTable(SHIPMENTS, { rowKey: (s) => s.id, pageSize: 3, preFilter: (s) => s.depot === depot, }); return (
); } ``` #### When to use something else - A restriction the user sees and can lift → Filter #### Keyboard The keys of [First table](#/first-table/keyboard-first-table), [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input), [Select](https://romanhaendler.github.io/umriss-ui/core/select/#keyboard-select) and [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button) apply here. #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`empty`](#/first-table/TableProps-empty) | `ReactNode` | — | What stands in the body when there are no rows. Empty because of a filter the table shows itself – with the way back. | | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | | [`pageSize`](#/first-table/TableOptions-pageSize) | `number` | `10` | How many rows a page holds. | | [`preFilter`](#/first-table/TableOptions-preFilter) | `(row: Z) => boolean` | — | Which rows the table has at all – by permission, by tenant, by anything the user is not meant to undo. It is invisible: never a condition, never reset, never part of the view, and "43 of 1,204" counts only the rows it admits. It may stand in the call; a change leaves the page standing, and the model clamps it. | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | | [`filter`](#/column/FieldColumn-filter) | [`FilterFor`](#/column/type-FilterFor)`` | — | A filter in the column header: `"list"` offers the values that occur, `"range"` two bounds (numbers or points in time), a filter from `columnFilter` asks whatever it asks itself. Its condition stands in the table toolbar. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | From `SearchProps` — the full table stands on [Search](#/search/type-SearchProps). | Prop | Type | Default | Description | |---|---|---|---| | [`placeholder`](#/search/SearchProps-placeholder) | `string` | the wording's "Search …" | The placeholder in the field. | From `ToolbarProps` — the full table stands on [Toolbar](#/toolbar/type-ToolbarProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/toolbar/ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. | From `PaginationProps` — the full table stands on [Pagination](#/pagination/type-PaginationProps). | Prop | Type | Default | Description | |---|---|---|---| | [`pageSizes`](#/pagination/PaginationProps-pageSizes) | `readonly number[]` | `[10, 25, 50]` | The selectable page sizes. | ## Grouping What rows have in common, read at a glance: groups on up to three levels, and what their values come to. ### Grouping Gather rows that share a value under one header with their count and totals (also called row grouping). Reach for it when the question is per customer, per team or per month rather than per record. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/grouping/ The outer levels become header rows that carry every total under its column; the innermost of several levels stands as a span beside its rows. The form follows the level, never the data ([ADR-0029](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0029-a-row-groups-form-follows-its-level.md)). Grouping comes after search and filters: a group holds what they leave, and its totals are taken from its rows, never from the groups inside it. Grouping is on for every table: the user groups from the column menu, up to three levels, and the table toolbar names the grouping. #### Examples ##### Group by one column `defaultGrouping` names a column; each tour gets a header row with its count and the sum under the weight column. ```tsx import { useTable } from "@umriss-ui/table"; interface Shipment { id: string; tour: string; customer: string; weight: number; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", tour: "T-01", customer: "Holloway Garden Supplies", weight: 12.4 }, { id: "FP-1004223", tour: "T-01", customer: "Oakridge Pharmacy", weight: 2.1 }, { id: "FP-1004236", tour: "T-01", customer: "Brixley Cycles", weight: 18.9 }, { id: "FP-1004470", tour: "T-02", customer: "Tamsin's Bakery", weight: 6.5 }, { id: "FP-1004483", tour: "T-02", customer: "Northfold Office", weight: 9.8 }, { id: "FP-1004730", tour: "T-03", customer: "Pellham Hardware", weight: 214.0 }, { id: "FP-1004743", tour: "T-03", customer: "Greywick Studio", weight: 188.5 }, ]; export default function OneLevel() { const { Table, Column } = useTable(SHIPMENTS, { rowKey: (s) => s.id, defaultGrouping: "tour" }); return (
); } ``` ##### Let people choose the grouping With a `ColumnMenu` people group without any code: under Grouping, the first choice is the outer level, up to three. ```tsx import { ColumnMenu, Toolbar, useTable } from "@umriss-ui/table"; interface Incident { id: string; service: string; severity: "SEV1" | "SEV2" | "SEV3"; assignee: string; minutes: number; } const INCIDENTS: Incident[] = [ { id: "INC-1048", service: "Checkout", severity: "SEV1", assignee: "Jonas Keller", minutes: 48 }, { id: "INC-1047", service: "Webhooks", severity: "SEV3", assignee: "Ines Duarte", minutes: 195 }, { id: "INC-1046", service: "Image service", severity: "SEV2", assignee: "Tomasz Nowak", minutes: 155 }, { id: "INC-1045", service: "Sign-in", severity: "SEV2", assignee: "Ada Mwangi", minutes: 45 }, { id: "INC-1043", service: "Search", severity: "SEV1", assignee: "Leila Haddad", minutes: 85 }, { id: "INC-1042", service: "Notifications", severity: "SEV3", assignee: "Sam Okafor", minutes: 255 }, { id: "INC-1041", service: "Billing", severity: "SEV2", assignee: "Priya Raman", minutes: 150 }, ]; export default function NothingDeclared() { const { Table, Column } = useTable(INCIDENTS, { rowKey: (i) => i.id }); return (
); } ``` ##### Group on two levels With two keys the cost centres get header rows with their totals, and the suppliers stand once, as a span beside their invoices. ```tsx import { ColumnMenu, Toolbar, useTable } from "@umriss-ui/table"; interface Invoice { id: string; costCentre: string; supplier: string; description: string; amount: number; discount: number | null; due: Date; } const day = (month: number, d: number) => new Date(2026, month - 1, d); const INVOICES: Invoice[] = [ { id: "INV-26-0318", costCentre: "Facilities", supplier: "Brandlow Office Supply", description: "Desk lamps", amount: 1056.24, discount: 117.36, due: day(4, 15) }, { id: "INV-26-0301", costCentre: "Facilities", supplier: "Brandlow Office Supply", description: "Printer paper", amount: 860, discount: 0, due: day(3, 31) }, { id: "INV-26-0288", costCentre: "Facilities", supplier: "Brandlow Office Supply", description: "Chairs", amount: 4210, discount: 210.5, due: day(3, 20) }, { id: "INV-26-0312", costCentre: "Facilities", supplier: "Hartwell Cleaning", description: "Cleaning, March", amount: 2400, discount: null, due: day(4, 10) }, { id: "INV-26-0290", costCentre: "Facilities", supplier: "Pennock Energy", description: "Electricity, February", amount: 3180, discount: 0, due: day(3, 24) }, { id: "INV-26-0317", costCentre: "Marketing", supplier: "Kettering & Shaw Events", description: "Trade fair stand", amount: 11_780, discount: 620, due: day(4, 12) }, { id: "INV-26-0305", costCentre: "Marketing", supplier: "Kettering & Shaw Events", description: "Catering", amount: 3330, discount: 0, due: day(4, 4) }, { id: "INV-26-0296", costCentre: "Marketing", supplier: "Lumen Print", description: "Brochures", amount: 1940, discount: 97, due: day(3, 28) }, { id: "INV-26-0284", costCentre: "Marketing", supplier: "Orla Ads", description: "Search ads, February", amount: 5600, discount: null, due: day(3, 18) }, { id: "INV-26-0309", costCentre: "IT", supplier: "Nimbrel Software", description: "Design tool licences", amount: 5508, discount: 972, due: day(4, 8) }, { id: "INV-26-0299", costCentre: "IT", supplier: "Nimbrel Software", description: "Onboarding session", amount: 1780, discount: 0, due: day(3, 29) }, { id: "INV-26-0293", costCentre: "IT", supplier: "Castrel Hosting", description: "Servers, February", amount: 2890, discount: 0, due: day(3, 25) }, { id: "INV-26-0311", costCentre: "Finance", supplier: "Fenwright Legal", description: "Contract review", amount: 3770, discount: 0, due: day(4, 9) }, ]; export default function TwoLevels() { const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id, defaultGrouping: ["costCentre", "supplier"] }); return (
); } ``` ##### Group on three levels Tier and team become header rows, the team one step in; the service is the span beside its incidents. Three is the most there is. ```tsx import { useTable } from "@umriss-ui/table"; interface Incident { id: string; tier: string; team: string; service: string; title: string; minutes: number; } const INCIDENTS: Incident[] = [ { id: "INC-1048", tier: "Tier 1", team: "Payments", service: "Checkout", title: "Card payments time out", minutes: 48 }, { id: "INC-1039", tier: "Tier 1", team: "Payments", service: "Checkout", title: "Basket totals off by a cent", minutes: 95 }, { id: "INC-1041", tier: "Tier 1", team: "Payments", service: "Billing", title: "Invoices generated twice", minutes: 150 }, { id: "INC-1045", tier: "Tier 1", team: "Identity", service: "Sign-in", title: "Sign-in codes arrive late", minutes: 45 }, { id: "INC-1043", tier: "Tier 1", team: "Discovery", service: "Search", title: "No results for some regions", minutes: 85 }, { id: "INC-1046", tier: "Tier 2", team: "Discovery", service: "Image service", title: "Thumbnails missing", minutes: 155 }, { id: "INC-1042", tier: "Tier 2", team: "Messaging", service: "Notifications", title: "Duplicate reminder e-mails", minutes: 255 }, { id: "INC-1047", tier: "Tier 2", team: "Integrations", service: "Webhooks", title: "Deliveries delayed", minutes: 195 }, { id: "INC-1044", tier: "Tier 3", team: "Insights", service: "Reporting", title: "Monthly export fails", minutes: 90 }, ]; export default function ThreeLevels() { const { Table, Column } = useTable(INCIDENTS, { rowKey: (i) => i.id, defaultGrouping: ["tier", "team", "service"] }); return (
); } ``` ##### Group dates by month `group="month"` brings each date to the start of its month; "day", "week" and "year" work the same, and the cells keep the full date. ```tsx import { useTable } from "@umriss-ui/table"; interface Invoice { id: string; supplier: string; received: Date; amount: number; } const INVOICES: Invoice[] = [ { id: "INV-26-0102", supplier: "Pennock Energy", received: new Date(2026, 0, 9), amount: 3420 }, { id: "INV-26-0131", supplier: "Castrel Hosting", received: new Date(2026, 0, 28), amount: 2890 }, { id: "INV-26-0204", supplier: "Brandlow Office Supply", received: new Date(2026, 1, 4), amount: 860 }, { id: "INV-26-0221", supplier: "Stellbrook Consulting", received: new Date(2026, 1, 21), amount: 8700 }, { id: "INV-26-0226", supplier: "Corrin Travel", received: new Date(2026, 1, 26), amount: 8117.2 }, { id: "INV-26-0309", supplier: "Nimbrel Software", received: new Date(2026, 2, 9), amount: 7288 }, { id: "INV-26-0318", supplier: "Brandlow Office Supply", received: new Date(2026, 2, 16), amount: 1951.24 }, ]; export default function ByMonth() { const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id, defaultGrouping: "received" }); return (
); } ``` ##### Group numbers in bands `groupValue` says what to group by instead of the value, here an amount band; bands stand small before large, and cells keep the amount. ```tsx import { useTable } from "@umriss-ui/table"; interface Invoice { id: string; supplier: string; amount: number; } const INVOICES: Invoice[] = [ { id: "INV-26-0318", supplier: "Brandlow Office Supply", amount: 1951.24 }, { id: "INV-26-0317", supplier: "Kettering & Shaw Events", amount: 15_110 }, { id: "INV-26-0309", supplier: "Nimbrel Software", amount: 7288 }, { id: "INV-26-0302", supplier: "Fenwright Legal", amount: 3770 }, { id: "INV-26-0296", supplier: "Lumen Print", amount: 640 }, { id: "INV-26-0290", supplier: "Pennock Energy", amount: 3180 }, { id: "INV-26-0284", supplier: "Orla Ads", amount: 480 }, ]; const band = (amount: number) => (amount < 1000 ? "Small (under €1,000)" : amount < 10_000 ? "Medium (€1,000–9,999)" : "Large (€10,000 and more)"); export default function InBands() { const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id, defaultGrouping: "size" }); return ( i.amount} label="Size" groupValue={band} format={{ decimals: 2 }} />
); } ``` ##### Group by a value that is no column `GroupBy` declares a group key computed from the row, here the day an incident opened; it has no cell and no export. ```tsx import { ColumnMenu, Toolbar, useTable } from "@umriss-ui/table"; interface Incident { id: string; service: string; opened: Date; minutes: number; } const at = (day: number, hours: number, minutes: number) => new Date(2026, 2, day, hours, minutes); const INCIDENTS: Incident[] = [ { id: "INC-1048", service: "Checkout", opened: at(17, 9, 42), minutes: 48 }, { id: "INC-1047", service: "Webhooks", opened: at(17, 7, 15), minutes: 195 }, { id: "INC-1046", service: "Image service", opened: at(16, 22, 5), minutes: 155 }, { id: "INC-1045", service: "Sign-in", opened: at(16, 14, 20), minutes: 45 }, { id: "INC-1044", service: "Reporting", opened: at(15, 10, 0), minutes: 90 }, { id: "INC-1043", service: "Search", opened: at(14, 18, 30), minutes: 85 }, { id: "INC-1042", service: "Notifications", opened: at(13, 8, 45), minutes: 255 }, ]; /* Day first, so that the groups sort by date. */ const dayOf = (d: Date) => `${d.getDate()} March, ${d.toLocaleDateString("en-GB", { weekday: "long" })}`; export default function KeyThatIsNoColumn() { const { Table, Column, GroupBy } = useTable(INCIDENTS, { rowKey: (i) => i.id, defaultGrouping: "day" }); return ( dayOf(i.opened)} label="Day" />
); } ``` ##### Order groups by their totals Sorting a column with an aggregate orders the groups by it, and the rows within; `defaultSort` starts with the heaviest tour on top. ```tsx import { useTable } from "@umriss-ui/table"; interface Shipment { id: string; tour: string; customer: string; weight: number; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", tour: "T-01", customer: "Holloway Garden Supplies", weight: 12.4 }, { id: "FP-1004223", tour: "T-01", customer: "Oakridge Pharmacy", weight: 2.1 }, { id: "FP-1004236", tour: "T-01", customer: "Brixley Cycles", weight: 18.9 }, { id: "FP-1004470", tour: "T-02", customer: "Tamsin's Bakery", weight: 6.5 }, { id: "FP-1004483", tour: "T-02", customer: "Northfold Office", weight: 9.8 }, { id: "FP-1004730", tour: "T-03", customer: "Pellham Hardware", weight: 214 }, { id: "FP-1004743", tour: "T-03", customer: "Greywick Studio", weight: 188.5 }, ]; export default function GroupsByAggregate() { const { Table, Column } = useTable(SHIPMENTS, { rowKey: (s) => s.id, defaultGrouping: "tour", defaultSort: { column: "weight", direction: "desc" }, }); return (
); } ``` ##### Fold groups into a summary Folded, a grouped table is one line per group with its totals; folds are part of the view and come back through `initialView`. ```tsx import { Button, Stack, Text } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; /* Data from the controlling 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; }; } interface CostCentre { id: string; name: string; owner: string; /** The budget of one month. */ monthlyBudget: number; } const COST_CENTRES: readonly CostCentre[] = [ { id: "CC-1100", name: "Sales", owner: "Helen Marsh", monthlyBudget: 142_000 }, { id: "CC-1200", name: "Marketing", owner: "Rafael Ortiz", monthlyBudget: 68_000 }, { id: "CC-2100", name: "Engineering", owner: "Anika Sørensen", monthlyBudget: 188_000 }, { id: "CC-2200", name: "Design", owner: "Paul Whitaker", monthlyBudget: 54_000 }, { id: "CC-3100", name: "Customer service", owner: "Grace Obi", monthlyBudget: 61_000 }, { id: "CC-4100", name: "Finance", owner: "Martina Vogel", monthlyBudget: 47_000 }, { id: "CC-4200", name: "People", owner: "Daniel Frost", monthlyBudget: 39_000 }, { id: "CC-4300", name: "IT", owner: "Kenji Arai", monthlyBudget: 83_000 }, { id: "CC-4400", name: "Facilities", owner: "Olga Ivanova", monthlyBudget: 72_000 }, ]; /** The year's months, as `"2026-01"` … `"2026-12"`. */ const MONTHS: readonly string[] = Array.from({ length: 12 }, (_, i) => `2026-${String(i + 1).padStart(2, "0")}`); /** The months whose books are closed: they have an actual. */ const CLOSED_MONTHS = 2; interface LedgerRow { costCentre: string; month: string; budget: number; /** `null` while the month is open. */ actual: number | null; forecast: number; } /* Where a cost centre lands against its budget, as a factor: Marketing's spring campaign overspends, IT's delayed licences underspend. */ const TENDENCY: Readonly> = { "CC-1200": 1.14, "CC-4300": 0.88 }; /** Budget, actual and forecast of every cost centre in every month. */ const LEDGER: readonly LedgerRow[] = COST_CENTRES.flatMap((centre, c) => { const r = random(310 + c); const tendency = TENDENCY[centre.id] ?? 1; return MONTHS.map((month, m) => { /* December pays the bonuses, August is the quiet month. */ const season = m === 11 ? 1.12 : m === 7 ? 0.9 : 1; const budget = Math.round(centre.monthlyBudget * season); const forecast = Math.round((budget * (tendency + (r() - 0.5) * 0.06)) / 100) * 100; const actual = m < CLOSED_MONTHS ? Math.round(forecast * (1 + (r() - 0.5) * 0.08)) : null; return { costCentre: centre.id, month, budget, actual, forecast }; }); }); const ROWS = LEDGER.map((row) => ({ key: `${row.costCentre} ${row.month}`, centre: COST_CENTRES.find((c) => c.id === row.costCentre)!.name, month: row.month, budget: row.budget, forecast: row.forecast, actual: row.actual, })); /* A fold is kept by the path of its group. */ const path = (...values: string[]) => JSON.stringify(values.map((v) => `value:${v}`)); export default function Overview() { const t = useTable(ROWS, { rowKey: (r) => r.key, defaultGrouping: "centre", initialView: { folded: COST_CENTRES.filter((c) => c.name !== "Marketing").map((c) => path(c.name)) }, }); const { Table, Column } = t; return (
{JSON.stringify(t.view)}
); } ``` ##### Select a whole group The checkbox in a group header selects all its rows, on other pages and in folded groups too; a bulk action receives the list. ```tsx import { Pagination, Toolbar, useTable } from "@umriss-ui/table"; interface Shipment { id: string; tour: string; customer: string; weight: number; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", tour: "T-01", customer: "Holloway Garden Supplies", weight: 12.4 }, { id: "FP-1004223", tour: "T-01", customer: "Oakridge Pharmacy", weight: 2.1 }, { id: "FP-1004236", tour: "T-01", customer: "Brixley Cycles", weight: 18.9 }, { id: "FP-1004249", tour: "T-01", customer: "Tamsin's Bakery", weight: 6.5 }, { id: "FP-1004483", tour: "T-02", customer: "Northfold Office", weight: 9.8 }, { id: "FP-1004496", tour: "T-02", customer: "Ashcombe Dental", weight: 3.2 }, { id: "FP-1004730", tour: "T-03", customer: "Pellham Hardware", weight: 214 }, { id: "FP-1004743", tour: "T-03", customer: "Greywick Studio", weight: 188.5 }, ]; export default function WholeGroup() { const { Table, Column, RowActions, Action } = useTable(SHIPMENTS, { rowKey: (s) => s.id, defaultGrouping: "tour", pageSize: 5, }); return ( window.alert(`Rescheduled: ${shipments.map((s) => s.id).join(", ")}`)}> Reschedule
); } ``` ##### Page through groups A page counts header rows and rows alike; a page that begins inside a group repeats its header, marked as continued. Turn to page two. ```tsx import { Pagination, useTable } from "@umriss-ui/table"; interface Invoice { id: string; costCentre: string; supplier: string; amount: number; } const INVOICES: Invoice[] = [ { id: "INV-26-0318", costCentre: "Facilities", supplier: "Brandlow Office Supply", amount: 1056.24 }, { id: "INV-26-0301", costCentre: "Facilities", supplier: "Brandlow Office Supply", amount: 860 }, { id: "INV-26-0312", costCentre: "Facilities", supplier: "Hartwell Cleaning", amount: 2400 }, { id: "INV-26-0290", costCentre: "Facilities", supplier: "Pennock Energy", amount: 3180 }, { id: "INV-26-0262", costCentre: "Facilities", supplier: "Pennock Energy", amount: 3420 }, { id: "INV-26-0231", costCentre: "Facilities", supplier: "Pennock Energy", amount: 3610 }, { id: "INV-26-0309", costCentre: "IT", supplier: "Castrel Hosting", amount: 2890 }, { id: "INV-26-0299", costCentre: "IT", supplier: "Kestrel Security", amount: 1200 }, { id: "INV-26-0293", costCentre: "IT", supplier: "Nimbrel Software", amount: 5508 }, { id: "INV-26-0271", costCentre: "IT", supplier: "Nimbrel Software", amount: 1780 }, ]; export default function Paging() { const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id, defaultGrouping: ["costCentre", "supplier"], pageSize: 5 }); return (
); } ``` ##### Search within groups Grouping comes after search and filters: type “pharmacy” and the tours without one disappear, the counts and sums shrink with them. ```tsx import { Search, Toolbar, useTable } from "@umriss-ui/table"; interface Shipment { id: string; tour: string; customer: string; status: string; weight: number; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", tour: "T-01", customer: "Holloway Garden Supplies", status: "delivered", weight: 12.4 }, { id: "FP-1004223", tour: "T-01", customer: "Oakridge Pharmacy", status: "delivered", weight: 2.1 }, { id: "FP-1004236", tour: "T-01", customer: "Brixley Cycles", status: "out for delivery", weight: 18.9 }, { id: "FP-1004470", tour: "T-02", customer: "Tamsin's Bakery", status: "failed attempt", weight: 6.5 }, { id: "FP-1004483", tour: "T-02", customer: "Northfold Office", status: "out for delivery", weight: 9.8 }, { id: "FP-1004730", tour: "T-03", customer: "Pellham Hardware", status: "delivered", weight: 214 }, { id: "FP-1004756", tour: "T-03", customer: "Oakridge Pharmacy", status: "out for delivery", weight: 183.2 }, ]; export default function FilterAndSearch() { const { Table, Column } = useTable(SHIPMENTS, { rowKey: (s) => s.id, defaultGrouping: "tour" }); return (
); } ``` ##### Switch grouping off `groupable={false}` on a column keeps it out of the grouping choice; on the `Table` it keeps a fixed report from being grouped at all. ```tsx import { Stack } from "@umriss-ui/core"; import { ColumnMenu, Toolbar, useTable } from "@umriss-ui/table"; interface Incident { id: string; service: string; severity: string; minutes: number; } const INCIDENTS: Incident[] = [ { id: "INC-1048", service: "Checkout", severity: "SEV1", minutes: 48 }, { id: "INC-1046", service: "Image service", severity: "SEV2", minutes: 155 }, { id: "INC-1045", service: "Sign-in", severity: "SEV2", minutes: 45 }, { id: "INC-1042", service: "Notifications", severity: "SEV3", minutes: 255 }, ]; export default function SwitchedOff() { const list = useTable(INCIDENTS, { rowKey: (i) => i.id }); const report = useTable(INCIDENTS, { rowKey: (i) => i.id }); return ( ); } ``` ##### Gather rows without a value Rows without a value form the group “No value”, which stands last in either sort direction. Click the Person header twice. ```tsx import { useTable } from "@umriss-ui/table"; interface WorkItem { id: string; person: string | null; name: string; estimate: number; } const WORK: WorkItem[] = [ { id: "W-102", person: "Arjun Mehta", name: "Profile page", estimate: 26 }, { id: "W-117", person: null, name: "Password reset e-mail", estimate: 8 }, { id: "W-105", person: "Chloe Durand", name: "Change of address form", estimate: 24 }, { id: "W-104", person: "Chloe Durand", name: "Basket keeps items across devices", estimate: 20 }, { id: "W-118", person: null, name: "Cookie banner wording", estimate: 3 }, { id: "W-109", person: "Eva Novak", name: "Regression run", estimate: 20 }, { id: "W-119", person: null, name: "Accessibility audit", estimate: 16 }, ]; export default function NoValue() { const { Table, Column } = useTable(WORK, { rowKey: (w) => w.id, defaultGrouping: "person" }); return (
); } ``` ##### Groups of a single row A group of one row keeps its fold, count and totals; as the innermost level it is a span, so it costs no extra line. ```tsx import { useTable } from "@umriss-ui/table"; interface WorkItem { id: string; team: string; person: string; name: string; estimate: number; } const WORK: WorkItem[] = [ { id: "W-101", team: "Web", person: "Arjun Mehta", name: "Sign-in with e-mail code", estimate: 18 }, { id: "W-102", team: "Web", person: "Arjun Mehta", name: "Profile page", estimate: 26 }, { id: "W-103", team: "Web", person: "Chloe Durand", name: "Session handling", estimate: 12 }, { id: "W-106", team: "Web", person: "Noah Fischer", name: "Profile page design", estimate: 14 }, { id: "W-108", team: "Web", person: "Eva Novak", name: "Test plan for sign-in", estimate: 10 }, { id: "W-110", team: "Apps", person: "Hana Sato", name: "Reminder scheduling service", estimate: 32 }, { id: "W-112", team: "Apps", person: "Kofi Mensah", name: "Calendar sync", estimate: 18 }, ]; export default function GroupOfOne() { const { Table, Column } = useTable(WORK, { rowKey: (w) => w.id, defaultGrouping: ["team", "person"] }); return (
); } ``` ##### Fit long group names in a compact table A long value in a span is cut and kept in its title, as every value in the table stays on its line; rows without a project gather last. ```tsx import { useTable } from "@umriss-ui/table"; interface WorkItem { id: string; team: string; project: string | null; person: string; name: string; estimate: number; } const PORTAL = "Member portal for Rowan Credit Union – sign-in, profile, change of address and statements"; const WORK: WorkItem[] = [ { id: "W-101", team: "Web", project: PORTAL, person: "Arjun Mehta", name: "Sign-in with e-mail code", estimate: 18 }, { id: "W-102", team: "Web", project: PORTAL, person: "Arjun Mehta", name: "Profile page", estimate: 26 }, { id: "W-106", team: "Web", project: PORTAL, person: "Noah Fischer", name: "Profile page design", estimate: 14 }, { id: "W-104", team: "Web", project: "Online shop relaunch", person: "Chloe Durand", name: "Basket keeps items across devices", estimate: 20 }, { id: "W-120", team: "Web", project: null, person: "Eva Novak", name: "Update the test devices", estimate: 4 }, { id: "W-121", team: "Web", project: null, person: "Maya Lindgren", name: "Quarterly planning", estimate: 6 }, { id: "W-110", team: "Apps", project: "Booking app", person: "Hana Sato", name: "Reminder scheduling service", estimate: 32 }, { id: "W-112", team: "Apps", project: "Booking app", person: "Kofi Mensah", name: "Calendar sync", estimate: 18 }, { id: "W-114", team: "Apps", project: "Booking app", person: "Kofi Mensah", name: "Reminder settings screen", estimate: 16 }, { id: "W-113", team: "Apps", project: "Intranet", person: "Kofi Mensah", name: "News feed", estimate: 16 }, ]; export default function CompactAndLong() { const { Table, Column } = useTable(WORK, { rowKey: (w) => w.id, defaultGrouping: ["team", "project", "person"] }); return (
); } ``` ##### Group twenty thousand rows Grouping works with `virtual`: the window counts header rows and rows alike, and headers stick one below the other while their group scrolls. ```tsx import { useMemo } from "react"; import { useTable } from "@umriss-ui/table"; interface Request { id: string; service: string; at: Date; duration: number; size: number; } const SERVICES = ["Checkout", "Billing", "Sign-in", "Search", "Image service", "Notifications", "Webhooks", "Reporting"] as const; /* mulberry32: the same seed yields the same requests on every run. */ function generate(count: number): Request[] { let seed = 20260317; const next = () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; /* Three days, one request every thirteen seconds, in the order they came. */ const start = new Date(2026, 2, 15, 6).getTime(); return Array.from({ length: count }, (_, i) => ({ id: `r${i}`, service: SERVICES[Math.floor(next() * SERVICES.length)]!, at: new Date(start + i * 13_000), duration: Math.round(20 + next() * 900), size: Math.round(next() * 5000) / 10, })); } /* The part of the day a request came in - a key, not a column. */ const partOf = (d: Date) => (d.getHours() < 6 ? "Night" : d.getHours() < 12 ? "Morning" : d.getHours() < 18 ? "Afternoon" : "Evening"); export default function TwentyThousand() { const requests = useMemo(() => generate(20_000), []); const { Table, Column, GroupBy } = useTable(requests, { rowKey: (r) => r.id, defaultGrouping: ["service", "part"], virtual: true, }); return ( partOf(r.at)} label="Part of the day" />
); } ``` ##### Read a day's deliveries by depot Everything at once: depot and part of the day, weights with their share, an on-time rate of its own, the worst delay, and a bulk action. ```tsx import type { LimitSet } from "@umriss-ui/core"; import { ColumnMenu, Export, Search, Toolbar, 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); const DEPOTS = [ { id: "north", name: "North depot" }, { id: "river", name: "Riverside depot" }, { id: "east", name: "East Gate depot" }, ] as const; 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; }), ); /* One row per stop: the shipment, its tour's depot, and how late the tour plans to arrive after the promised window closes. */ const STOPS = TOURS.flatMap((tour) => tour.stops.map((stop) => ({ id: stop.shipment, depot: DEPOTS.find((d) => d.id === tour.depot)!.name, tour: tour.id, window: new Date(stop.window[0]), customer: stop.customer, weight: SHIPMENTS.find((s) => s.id === stop.shipment)!.weight, delay: Math.max(0, Math.round((stop.arrival - stop.window[1]) / MINUTE)), })), ); type StopRow = (typeof STOPS)[number]; const DELAY: LimitSet = { limits: [ { value: 1, side: "upper", severity: "warning" }, { value: 45, side: "upper", severity: "alarm" }, ], }; const partOf = (d: Date) => (d.getHours() < 12 ? "Morning" : "Afternoon"); /* Stops on time over all stops of the group - a rate is the application's to compute, never an average of rates. */ const onTime = (_values: readonly number[], stops: readonly StopRow[]) => stops.filter((s) => s.delay === 0).length / stops.length; export default function DispatchReport() { const { Table, Column, VerdictColumn, GroupBy, RowActions, Action } = useTable(STOPS, { rowKey: (s) => s.id, defaultGrouping: ["depot", "part"], }); return ( partOf(s.window)} label="Part of the day" /> Number(s.delay === 0)} label="On time" format="percent" aggregate={onTime} /> window.alert(`Rescheduled: ${stops.map((s) => s.id).join(", ")}`)}> Reschedule
); } ``` ##### Group and fold from controls of your own `t.setGrouping` groups by what a control of the application chooses, `[]` ungroups; `t.toggleFold` folds one group by its path, and `t.folded` holds the paths folded now. ```tsx import { Button, Select, Stack } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; interface Shipment { id: string; tour: string; customer: string; status: "delivered" | "out for delivery"; weight: number; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", tour: "T-01", customer: "Holloway Garden Supplies", status: "delivered", weight: 12.4 }, { id: "FP-1004223", tour: "T-01", customer: "Oakridge Pharmacy", status: "delivered", weight: 2.1 }, { id: "FP-1004236", tour: "T-01", customer: "Brixley Cycles", status: "delivered", weight: 18.9 }, { id: "FP-1004470", tour: "T-02", customer: "Tamsin's Bakery", status: "delivered", weight: 6.5 }, { id: "FP-1004483", tour: "T-02", customer: "Northfold Office", status: "out for delivery", weight: 9.8 }, { id: "FP-1004730", tour: "T-03", customer: "Pellham Hardware", status: "delivered", weight: 214.0 }, { id: "FP-1004743", tour: "T-03", customer: "Greywick Studio", status: "delivered", weight: 188.5 }, ]; /* A fold is kept by the path of its group. */ const path = (...values: string[]) => JSON.stringify(values.map((v) => `value:${v}`)); /** The tours with nothing left to deliver - what a dispatcher folds away. */ const FINISHED = [...new Set(SHIPMENTS.map((s) => s.tour))].filter((tour) => SHIPMENTS.every((s) => s.tour !== tour || s.status === "delivered"), ); export default function GroupAndFoldFromControls() { const t = useTable(SHIPMENTS, { rowKey: (s) => s.id, defaultGrouping: "tour" }); const { Table, Column } = t; const byTour = t.grouping[0] === "tour"; const open = FINISHED.filter((tour) => !t.folded.includes(path(tour))); return (
); } ``` #### When to use something else - Only the totals of the whole list → Aggregate #### Keyboard | Key | Action | |---|---| | `→ / ←` | On a fold: unfolds or folds its group. | | `Alt + → / Alt + ←` | On a fold: unfolds or folds the group and all its siblings. | #### API ##### `GroupByBase` | Prop | Type | Default | Description | |---|---|---|---| | `label` *required* | `string` | — | Names the group key where a grouping is chosen, and in the grouping's tag in the table toolbar.
Shown in: [Group by a value that is no column](#/grouping/a-key-that-is-no-column), [Group twenty thousand rows](#/grouping/twenty-thousand-rows), [Read a day's deliveries by depot](#/grouping/the-dispatch-report) | #### Known limits - No pivot and no more than three levels ([ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/)). - No bar to drag headers onto: grouping is chosen in the column menu. - No groups in [server mode](#/server-mode): they would be made of one page. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### Tree rows Rows that hold rows: a hierarchy of any number of levels whose every level carries the same figures (also called tree data). Reach for it to drill down an organisation, regions and places or cost centres - levels the application holds, uneven as they are. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/tree-rows/ `childRows` names the children of a row; the rows passed in are the roots. Every row with children gets a fold in the first column, and the next level stands beneath it, indented. The figures of a row are the application's: the table sums nothing for a branch. The footer sums the top level only, since a parent already holds its children. Every level sorts on its own, and a child never leaves its parent. A search or a filter shows a match with the rows above it, muted, and leaves the open branches as they were. The export writes every row of the filtered tree, open or not, with its level first. #### Examples ##### A small tree Name the children of a row with `childRows`: the table shows the roots, and every row with children gets a fold in the first column that opens the next level beneath it. ```tsx import { useTable } from "@umriss-ui/table"; interface Unit { id: string; name: string; headcount: number; children?: Unit[]; } const UNITS: Unit[] = [ { id: "sales", name: "Sales", headcount: 42, children: [ { id: "sales-north", name: "Sales North", headcount: 18 }, { id: "sales-south", name: "Sales South", headcount: 24 }, ], }, { id: "finance", name: "Finance", headcount: 11 }, { id: "operations", name: "Operations", headcount: 96, children: [ { id: "site", name: "Site Ashcombe", headcount: 81 }, { id: "logistics", name: "Logistics", headcount: 15 }, ], }, ]; export default function ASmallTree() { const { Table, Column } = useTable(UNITS, { rowKey: (u) => u.id, childRows: (u) => u.children }); return (
); } ``` ##### An uneven organisation Five levels in one division, one in the staff unit - each unit with figures of its own. The table opens the first level, sorts every level on its own, and the footer sums the top level only: a parent already holds its children. Search for “line” and the matches come with the rows above them, muted. ```tsx import { Search, Toolbar, ColumnMenu, useTable } from "@umriss-ui/table"; interface Unit { id: string; name: string; budget: number; spent: number; children?: Unit[]; } const UNITS: Unit[] = [ { id: "ops", name: "Operations", budget: 4_800, spent: 4_950, children: [ { id: "ops-ash", name: "Site Ashcombe", budget: 3_100, spent: 3_320, children: [ { id: "ops-ash-a", name: "Hall A", budget: 1_700, spent: 1_910, children: [ { id: "ops-ash-a-1", name: "Line 1", budget: 900, spent: 1_080, children: [ { id: "ops-ash-a-1-press", name: "Press cell", budget: 520, spent: 690 }, { id: "ops-ash-a-1-weld", name: "Welding cell", budget: 380, spent: 390 }, ], }, { id: "ops-ash-a-2", name: "Line 2", budget: 800, spent: 830 }, ], }, { id: "ops-ash-b", name: "Hall B", budget: 1_400, spent: 1_410 }, ], }, { id: "ops-log", name: "Logistics", budget: 1_700, spent: 1_630 }, ], }, { id: "sales", name: "Sales", budget: 2_200, spent: 2_050, children: [ { id: "sales-n", name: "Region North", budget: 1_200, spent: 1_150 }, { id: "sales-s", name: "Region South", budget: 1_000, spent: 900 }, ], }, { id: "staff", name: "Staff unit", budget: 600, spent: 610 }, ]; export default function AnUnevenOrganisation() { const { Table, Column } = useTable(UNITS, { rowKey: (u) => u.id, childRows: (u) => u.children, defaultBranches: 1 }); return ( u.spent - u.budget} id="delta" label="Δ (k€)" />
); } ``` ##### Regions and places One country has states, another goes straight to its cities: the tree stands as deep as each branch is. A column only places carry stays empty on a country - one column set for every level, absent where a level has no value. ```tsx import { useTable } from "@umriss-ui/table"; interface Place { id: string; name: string; sites: number; /** Only places have a postcode. */ postcode?: string; children?: Place[]; } const PLACES: Place[] = [ { id: "de", name: "Germany", sites: 5, children: [ { id: "de-by", name: "Bavaria", sites: 3, children: [ { id: "de-by-muc", name: "Munich", sites: 2, postcode: "80331" }, { id: "de-by-nue", name: "Nuremberg", sites: 1, postcode: "90402" }, ], }, { id: "de-hh", name: "Hamburg", sites: 2, postcode: "20095" }, ], }, { id: "nl", name: "Netherlands", sites: 2, children: [ { id: "nl-ams", name: "Amsterdam", sites: 1, postcode: "1012" }, { id: "nl-rtm", name: "Rotterdam", sites: 1, postcode: "3011" }, ], }, { id: "lu", name: "Luxembourg", sites: 1, postcode: "1009" }, ]; export default function RegionsAndPlaces() { const { Table, Column } = useTable(PLACES, { rowKey: (p) => p.id, childRows: (p) => p.children, defaultBranches: 2 }); return (
); } ``` ##### Employees in the detail When the bottom of the hierarchy is records of another kind, the tree carries the summarising columns and a team's `RowDetail` holds its employees - a table of their own, with every field they have and their own sort. In a tree the team's fold opens them: they hang from it a level deeper, on the table's surface, without a box. ```tsx import { useTable } from "@umriss-ui/table"; interface Employee { id: string; name: string; role: string; since: string; hours: number; } interface Team { id: string; name: string; headcount: number; hours: number; employees?: Employee[]; children?: Team[]; } const TEAMS: Team[] = [ { id: "it", name: "IT", headcount: 5, hours: 196, children: [ { id: "it-ops", name: "IT Operations", headcount: 3, hours: 118, employees: [ { id: "e1", name: "Ada Brenner", role: "Lead", since: "2016", hours: 40 }, { id: "e2", name: "Jonas Kessler", role: "Administrator", since: "2021", hours: 40 }, { id: "e3", name: "Mira Otto", role: "Administrator", since: "2024", hours: 38 }, ], }, { id: "it-dev", name: "Development", headcount: 2, hours: 78, employees: [ { id: "e4", name: "Lena Vogt", role: "Developer", since: "2019", hours: 40 }, { id: "e5", name: "Tom Weiss", role: "Developer", since: "2023", hours: 38 }, ], }, ], }, ]; function Employees({ team }: { team: Team }) { const { Table, Column } = useTable(team.employees ?? [], { rowKey: (e) => e.id, defaultSort: { column: "name", direction: "asc" } }); return (
); } export default function EmployeesInTheDetail() { const { Table, Column, RowDetail } = useTable(TEAMS, { rowKey: (t) => t.id, childRows: (t) => t.children, defaultBranches: 1 }); return ( {(team) => (team.employees ? : null)}
); } ``` ##### Own staff and teams A unit can have people of its own and teams beneath it. Its fold opens both: first its own staff on the rail, then its teams a level deeper - one chevron for everything that stands under the row. A unit without staff of its own opens only its teams; a team without teams only its staff. ```tsx import { useTable } from "@umriss-ui/table"; interface Employee { id: string; name: string; role: string; hours: number; } interface Unit { id: string; name: string; headcount: number; hours: number; /** The people the unit has itself - not those of its teams. */ staff?: Employee[]; children?: Unit[]; } const UNITS: Unit[] = [ { id: "it", name: "IT", headcount: 6, hours: 236, staff: [{ id: "e0", name: "Nora Albers", role: "Head of IT", hours: 40 }], children: [ { id: "it-ops", name: "IT Operations", headcount: 3, hours: 118, staff: [ { id: "e1", name: "Ada Brenner", role: "Lead", hours: 40 }, { id: "e2", name: "Jonas Kessler", role: "Administrator", hours: 40 }, { id: "e3", name: "Mira Otto", role: "Administrator", hours: 38 }, ], }, { id: "it-dev", name: "Development", headcount: 2, hours: 78, staff: [ { id: "e4", name: "Lena Vogt", role: "Developer", hours: 40 }, { id: "e5", name: "Tom Weiss", role: "Developer", hours: 38 }, ], }, ], }, { id: "fin", name: "Finance", headcount: 2, hours: 72, children: [ { id: "fin-ctl", name: "Controlling", headcount: 2, hours: 72, staff: [ { id: "e6", name: "Paula Hartmann", role: "Controller", hours: 40 }, { id: "e7", name: "Ben Lindner", role: "Analyst", hours: 32 }, ], }, ], }, ]; function Staff({ unit }: { unit: Unit }) { const { Table, Column } = useTable(unit.staff ?? [], { rowKey: (e) => e.id }); return (
); } export default function OwnStaffAndTeams() { const { Table, Column, RowDetail } = useTable(UNITS, { rowKey: (u) => u.id, childRows: (u) => u.children, defaultBranches: ["it"] }); return ( {(unit) => (unit.staff ? : null)}
); } ``` ##### From a flat list Data from a database often comes flat, each record with its parent's id. A few lines nest it; the table takes the nested rows. ```tsx import { useMemo } from "react"; import { useTable } from "@umriss-ui/table"; interface CostCentre { id: string; parent: string | null; name: string; amount: number; } const FLAT: CostCentre[] = [ { id: "1000", parent: null, name: "Administration", amount: 820 }, { id: "1100", parent: "1000", name: "Accounting", amount: 310 }, { id: "1110", parent: "1100", name: "Payroll", amount: 120 }, { id: "1200", parent: "1000", name: "Legal", amount: 190 }, { id: "2000", parent: null, name: "Production", amount: 4_300 }, { id: "2100", parent: "2000", name: "Assembly", amount: 2_600 }, ]; type Nested = CostCentre & { children?: Nested[] }; /** Nests a flat list by its parent ids - the roots are those without one. */ function nest(flat: readonly CostCentre[]): Nested[] { const byId = new Map(flat.map((c) => [c.id, { ...c }])); const roots: Nested[] = []; for (const c of byId.values()) { const parent = c.parent === null ? undefined : byId.get(c.parent); if (parent) (parent.children ??= []).push(c); else roots.push(c); } return roots; } export default function FromAFlatList() { const rows = useMemo(() => nest(FLAT), []); const { Table, Column } = useTable(rows, { rowKey: (c) => c.id, childRows: (c) => c.children, defaultBranches: 1 }); return (
); } ``` ##### Open branches from controls of your own `t.unfoldAllBranches` and `t.foldAllBranches` open and close the whole tree; `t.toggleBranch` opens one branch by its key, and `t.branches` holds the keys open now - here to lead from a warning down to its unit. ```tsx import { Button, Stack, Text } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; interface Unit { id: string; name: string; budget: number; spent: number; children?: Unit[]; } const UNITS: Unit[] = [ { id: "ops", name: "Operations", budget: 3_400, spent: 3_560, children: [ { id: "ops-ash", name: "Site Ashcombe", budget: 2_600, spent: 2_790, children: [ { id: "ops-ash-press", name: "Press shop", budget: 1_500, spent: 1_720 }, { id: "ops-ash-paint", name: "Paint shop", budget: 1_100, spent: 1_070 }, ], }, { id: "ops-log", name: "Logistics", budget: 800, spent: 770 }, ], }, { id: "sales", name: "Sales", budget: 1_200, spent: 1_150, children: [ { id: "sales-north", name: "Sales North", budget: 700, spent: 690 }, { id: "sales-south", name: "Sales South", budget: 500, spent: 460 }, ], }, { id: "finance", name: "Finance", budget: 400, spent: 390 }, ]; /** The warning the controlling system raised, with the branches above its unit. */ const WARNING = { unit: "Press shop", path: ["ops", "ops-ash"] }; export default function OpenBranchesFromControls() { const t = useTable(UNITS, { rowKey: (u) => u.id, childRows: (u) => u.children }); const { Table, Column } = t; const closed = WARNING.path.filter((key) => !t.branches.includes(key)); return ( {WARNING.unit} is over budget.
); } ``` #### When to use something else - Rows that share a value - a customer, a month → Grouping - A hierarchy without figures → `TreeView` from @umriss-ui/core - Records of another kind beneath the bottom level → RowDetail #### Keyboard | Key | Action | |---|---| | `→ / ←` | On a fold: opens or closes its branch; ← on a closed one goes to the parent's fold. | | `Alt + → / Alt + ←` | On a fold: opens or closes the branch and all its siblings. | #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`density`](#/first-table/TableProps-density) | `"regular" \| "compact"` | the density of the `UmrissProvider`, else `"regular"` | How tightly the rows stand. | | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | | [`defaultSort`](#/first-table/TableOptions-defaultSort) | [`SortLevel`](#/first-table/type-SortLevel)` \| readonly `[`SortLevel`](#/first-table/type-SortLevel)`[] \| null` | `null` | The application's sort; it is the default that `view` leaves out. | From `TableSnapshot` — the full table stands on [First table](#/first-table/type-TableSnapshot). | Prop | Type | Default | Description | |---|---|---|---| | [`branches`](#/first-table/TableSnapshot-branches) *required* | `readonly string[]` | — | Tree rows: the keys of the open branches. | | [`toggleBranch`](#/first-table/TableSnapshot-toggleBranch) *required* | `(key: string) => void` | — | Tree rows: opens a branch or closes it. | | [`unfoldAllBranches`](#/first-table/TableSnapshot-unfoldAllBranches) *required* | `() => void` | — | Tree rows: opens every branch that has children. | | [`foldAllBranches`](#/first-table/TableSnapshot-foldAllBranches) *required* | `() => void` | — | Tree rows: closes every branch. | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | | [`searchable`](#/column/ColumnBase-searchable) | `boolean` | `true` for text, `false` otherwise | Takes part in the search. | From `AggregateOptions` — the full table stands on [Aggregate](#/aggregate/type-AggregateOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`aggregate`](#/aggregate/AggregateOptions-aggregate) | [`AggregateFor`](#/aggregate/type-AggregateFor)`` | — | What the column's values come to: in the footer over the filtered set, in a group's header over its rows. `"sum"`, `"avg"` for numbers; `"min"`, `"max"` for numbers and points in time; `"range"` for points in time; `"count"`, `"distinct"` for every value with a text form - or a function of one's own. Always from the values, never from other aggregates; absent values count towards nothing. | From `RowDetailProps` — the full table stands on [RowDetail](#/rowdetail/type-RowDetailProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/rowdetail/RowDetailProps-children) *required* | `(row: Z) => ReactNode` | — | What stands underneath an expanded row. | From `ToolbarProps` — the full table stands on [Toolbar](#/toolbar/type-ToolbarProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/toolbar/ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. | #### Known limits - Not with grouping or pagination: both would build or cut levels a second way. A large tree uses virtualisation. - Not in [server mode](#/server-mode), and no branches loaded on demand yet. - One column set for every level; a level without a value shows it as absent. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### Aggregate What a column's values come to: a sum, an average, the extremes, a count. It stands in the footer over the filtered rows and in each group header over the group's rows. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/aggregate/ An aggregate is always computed from rows: a group's average is over its rows, not over the averages of the groups inside it. Absent values do not count. A rate or a weighted average is the application's to say, with an aggregate of its own. #### Examples ##### Total a column `aggregate="sum"` and `"avg"` fill the footer over what search and filters leave; an average counts only the values present. ```tsx import { Search, Toolbar, useTable } from "@umriss-ui/table"; interface Tour { id: string; depot: string; km: number; stops: number; load: number | null; } const TOURS: Tour[] = [ { id: "T-01", depot: "North depot", km: 84, stops: 11, load: 0.72 }, { id: "T-02", depot: "North depot", km: 91, stops: 9, load: 0.64 }, { id: "T-03", depot: "North depot", km: 152, stops: 5, load: 0.88 }, { id: "T-04", depot: "Riverside depot", km: 77, stops: 10, load: null }, { id: "T-05", depot: "Riverside depot", km: 102, stops: 8, load: 0.58 }, ]; export default function SumAndAverage() { const { Table, Column } = useTable(TOURS, { rowKey: (t) => t.id }); return (
); } ``` ##### Find the extremes `"min"` and `"max"` take the extremes of numbers and dates alike, written in the column's format. ```tsx import { useTable } from "@umriss-ui/table"; interface Incident { id: string; toAcknowledge: number; opened: Date; } const at = (day: number, hours: number, minutes: number) => new Date(2026, 2, day, hours, minutes); const INCIDENTS: Incident[] = [ { id: "INC-1048", toAcknowledge: 4, opened: at(17, 9, 42) }, { id: "INC-1047", toAcknowledge: 47, opened: at(17, 7, 15) }, { id: "INC-1046", toAcknowledge: 6, opened: at(16, 22, 5) }, { id: "INC-1045", toAcknowledge: 4, opened: at(16, 14, 20) }, { id: "INC-1044", toAcknowledge: 1392, opened: at(15, 10, 0) }, ]; export default function MinimumAndMaximum() { const { Table, Column } = useTable(INCIDENTS, { rowKey: (i) => i.id }); return (
); } ``` ##### Show a range of dates `"range"` gives the earliest and latest date of each group, so a project's header says from when to when its work runs. ```tsx import { useTable } from "@umriss-ui/table"; interface WorkItem { id: string; project: string; person: string; name: string; starts: Date; } const day = (d: number) => new Date(2026, 2, d); const WORK: WorkItem[] = [ { id: "W-101", project: "Member portal", person: "Arjun Mehta", name: "Sign-in with e-mail code", starts: day(9) }, { id: "W-102", project: "Member portal", person: "Arjun Mehta", name: "Profile page", starts: day(12) }, { id: "W-105", project: "Member portal", person: "Chloe Durand", name: "Change of address form", starts: day(16) }, { id: "W-109", project: "Member portal", person: "Eva Novak", name: "Regression run", starts: day(17) }, { id: "W-104", project: "Online shop relaunch", person: "Chloe Durand", name: "Basket keeps items across devices", starts: day(11) }, { id: "W-107", project: "Online shop relaunch", person: "Noah Fischer", name: "Search results layout", starts: day(16) }, { id: "W-110", project: "Booking app", person: "Hana Sato", name: "Reminder scheduling service", starts: day(9) }, { id: "W-115", project: "Booking app", person: "Freya Olsen", name: "Store screenshots", starts: day(18) }, ]; export default function RangeOfDates() { const { Table, Column } = useTable(WORK, { rowKey: (w) => w.id, defaultGrouping: ["project", "person"] }); return (
); } ``` ##### Count values and distinct values `"count"` counts the values present, `"distinct"` the different ones: eight alerts, but three services and two people assigned. ```tsx import { useTable } from "@umriss-ui/table"; interface Alert { id: string; service: string; assignee: string | null; minutes: number; } const ALERTS: Alert[] = [ { id: "A-2201", service: "Checkout", assignee: "Jonas Keller", minutes: 18 }, { id: "A-2202", service: "Checkout", assignee: "Jonas Keller", minutes: 42 }, { id: "A-2203", service: "Search", assignee: "Leila Haddad", minutes: 21 }, { id: "A-2204", service: "Search", assignee: null, minutes: 7 }, { id: "A-2205", service: "Webhooks", assignee: null, minutes: 35 }, { id: "A-2206", service: "Checkout", assignee: "Leila Haddad", minutes: 16 }, { id: "A-2207", service: "Webhooks", assignee: null, minutes: 25 }, { id: "A-2208", service: "Search", assignee: "Jonas Keller", minutes: 4 }, ]; export default function CountAndDistinct() { const { Table, Column } = useTable(ALERTS, { rowKey: (a) => a.id }); return (
); } ``` ##### Show each group's share Under a group's sum stands a thin bar for its share of the whole; `share={false}` takes it away where a share says nothing. ```tsx import { useTable } from "@umriss-ui/table"; interface Invoice { id: string; costCentre: string; supplier: string; amount: number; lines: number; } const INVOICES: Invoice[] = [ { id: "INV-26-0318", costCentre: "Facilities", supplier: "Brandlow Office Supply", amount: 1951.24, lines: 3 }, { id: "INV-26-0312", costCentre: "Facilities", supplier: "Hartwell Cleaning", amount: 2400, lines: 1 }, { id: "INV-26-0301", costCentre: "Facilities", supplier: "Brandlow Office Supply", amount: 860, lines: 1 }, { id: "INV-26-0317", costCentre: "Marketing", supplier: "Kettering & Shaw Events", amount: 15_110, lines: 2 }, { id: "INV-26-0296", costCentre: "Marketing", supplier: "Lumen Print", amount: 1940, lines: 4 }, { id: "INV-26-0309", costCentre: "IT", supplier: "Nimbrel Software", amount: 7288, lines: 2 }, ]; export default function ShareBar() { const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id, defaultGrouping: ["costCentre", "supplier"] }); return ( {/* The amount with its bar right beside the groups: after the invoice it stood out of view on a phone. */}
); } ``` ##### Weight a rate by its rows Averaging rates weighs a small tour like a large one; divide the sums with an aggregate of your own, and header and footer are right. ```tsx import { useTable } from "@umriss-ui/table"; interface Tour { id: string; depot: string; parcels: number; failed: number; } const TOURS: Tour[] = [ { id: "T-01", depot: "North depot", parcels: 410, failed: 5 }, { id: "T-02", depot: "North depot", parcels: 380, failed: 3 }, { id: "T-03", depot: "North depot", parcels: 12, failed: 3 }, { id: "T-04", depot: "Riverside depot", parcels: 520, failed: 6 }, { id: "T-05", depot: "Riverside depot", parcels: 460, failed: 4 }, ]; const rate = (t: Tour) => t.failed / t.parcels; export default function NeverAnAverageOfAverages() { const { Table, Column } = useTable(TOURS, { rowKey: (t) => t.id, defaultGrouping: "depot" }); return ( tours.reduce((s, t) => s + t.failed, 0) / tours.reduce((s, t) => s + t.parcels, 0)} />
); } ``` ##### Write an aggregate of your own A function gets the values present and the group's rows and returns a value of the column's type; here a unit price weighted by quantity. ```tsx import { useTable } from "@umriss-ui/table"; interface Line { id: string; costCentre: string; description: string; quantity: number; unitPrice: number; } const LINES: Line[] = [ { id: "L-1", costCentre: "Facilities", description: "Desk lamps, LED", quantity: 24, unitPrice: 48.9 }, { id: "L-2", costCentre: "Facilities", description: "Printer paper, box", quantity: 40, unitPrice: 21.5 }, { id: "L-3", costCentre: "Facilities", description: "Delivery", quantity: 1, unitPrice: 35 }, { id: "L-4", costCentre: "IT", description: "Design tool licences", quantity: 12, unitPrice: 459 }, { id: "L-5", costCentre: "IT", description: "Onboarding session", quantity: 2, unitPrice: 890 }, ]; export default function OwnAggregate() { const { Table, Column } = useTable(LINES, { rowKey: (l) => l.id, defaultGrouping: "costCentre" }); return ( lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0) / lines.reduce((s, l) => s + l.quantity, 0)} />
); } ``` ##### Carry the worst verdict up On a `VerdictColumn`, `aggregate="worst"` shows the worst verdict among a group's rows; fold a zone and its line still shows the alarm. ```tsx import type { LimitSet } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; /* Data from the plant world, written out here so the example runs on its own. */ /** The kiln's zone 3, in °C: the numbers every part reads it against, once. Between the two warnings lies the tolerance - a tile fired outside it is scrap; `returnTo` is the alarm's dead band. */ const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const; /** The same numbers as the limit set the tile and the verdict read. */ const KILN_LIMITS: LimitSet = { target: KILN.target, limits: [ { value: KILN.tolerance[1], side: "upper", severity: "warning" }, { value: KILN.alarm, side: "upper", severity: "alarm" }, { value: KILN.tolerance[0], side: "lower", severity: "warning" }, ], }; interface Reading { id: string; kiln: string; zone: string; at: string; temperature: number | null; } const READINGS: Reading[] = [ { id: "Z1-0600", kiln: "Kiln K1", zone: "Zone 1", at: "06:00", temperature: 1198.4 }, { id: "Z1-0700", kiln: "Kiln K1", zone: "Zone 1", at: "07:00", temperature: 1201.2 }, { id: "Z1-0800", kiln: "Kiln K1", zone: "Zone 1", at: "08:00", temperature: 1203.9 }, { id: "Z2-0600", kiln: "Kiln K1", zone: "Zone 2", at: "06:00", temperature: 1209.5 }, { id: "Z2-0700", kiln: "Kiln K1", zone: "Zone 2", at: "07:00", temperature: 1217.8 }, { id: "Z2-0800", kiln: "Kiln K1", zone: "Zone 2", at: "08:00", temperature: 1212 }, { id: "Z3-0600", kiln: "Kiln K1", zone: "Zone 3", at: "06:00", temperature: 1224.1 }, { id: "Z3-0700", kiln: "Kiln K1", zone: "Zone 3", at: "07:00", temperature: 1233.6 }, { id: "Z3-0800", kiln: "Kiln K1", zone: "Zone 3", at: "08:00", temperature: null }, ]; export default function WorstVerdict() { const { Table, Column, VerdictColumn } = useTable(READINGS, { rowKey: (r) => r.id, defaultGrouping: ["kiln", "zone"] }); return (
); } ``` #### When to use something else - A key figure outside the table → `Stat` from @umriss-ui/core #### Keyboard The keys of [First table](#/first-table/keyboard-first-table) and [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input) apply here. #### API ##### `AggregateOptions` | Prop | Type | Default | Description | |---|---|---|---| | `aggregate` | [`AggregateFor`](#type-AggregateFor)`` | — | What the column's values come to: in the footer over the filtered set, in a group's header over its rows. `"sum"`, `"avg"` for numbers; `"min"`, `"max"` for numbers and points in time; `"range"` for points in time; `"count"`, `"distinct"` for every value with a text form - or a function of one's own. Always from the values, never from other aggregates; absent values count towards nothing.
Shown in: [Total a column](#/aggregate/sum-and-average), [Find the extremes](#/aggregate/minimum-and-maximum), [Show a range of dates](#/aggregate/range-of-dates) and 34 more | | `share` | `boolean` | — | The share bar in a group's header – the group's share of the filtered set's sum. It stands under the first column with a sum, one per header; `false` passes it on to the next.
Shown in: [Show each group's share](#/aggregate/share-bar), [Weight a rate by its rows](#/aggregate/never-an-average-of-averages), [Share a column between tables (Presets)](#/presets/presets) and 2 more | ##### Types on this page ###### `AggregateFor` What a column accepts as `aggregate`: sums and averages for numbers, the extremes for numbers and points in time, the range for points in time, counts for every value with a text form - or a function of one's own. ```ts type AggregateFor = | ([Present] extends [number] ? "sum" | "avg" | "min" | "max" : never) | ([Present] extends [Date] ? "min" | "max" | "range" : never) | (IsDisplayable extends true ? "count" | "distinct" : never) | AggregateFunction; ``` ###### `Present` The value without the absence - that is what `children` gets. ```ts type Present = Exclude; ``` ###### `IsDisplayable` `true` where the value present can appear as text without `children` (`Displayable`), `false` otherwise - what decides the filters and aggregates a column offers. ```ts type IsDisplayable = [Present] extends [never] ? false : [Present] extends [Displayable] ? true : false; ``` ###### `AggregateFunction` An aggregate of one's own: the values present and the rows of the group - or of the filtered set, in the footer. Its result runs through the column's presentation; `undefined` means there is none. ```ts type AggregateFunction = (values: readonly Present>[], rows: readonly Z[]) => Present> | undefined; ``` ###### `Absent` A value that is not there: `null` or `undefined`. ```ts type Absent = null | undefined; ``` ###### `Displayable` What can appear as text without `children`. ```ts type Displayable = string | number | boolean | Date; ``` #### Known limits - No footer in [server mode](#/server-mode): a sum over one page would misstate the server's set. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ## Rows What a row carries besides its values: a tick, a look, a detail and actions. ### Selection Tick rows to act on them together (also called row selection or multi-select). ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/selection/ “Select all” selects what search and filters leave, on every page. #### Examples ##### Select rows `selectable` adds the selection column; “select all” takes what the search leaves. Search “Oak”, select all, clear the search. ```tsx import { Stack, Text } from "@umriss-ui/core"; import { Search, Toolbar, useTable } from "@umriss-ui/table"; interface Shipment { id: string; customer: string; tour: string; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", customer: "Holloway Garden Supplies", tour: "T-01" }, { id: "FP-1004223", customer: "Oakridge Pharmacy", tour: "T-01" }, { id: "FP-1004236", customer: "Brixley Cycles", tour: "T-02" }, { id: "FP-1004249", customer: "Oakridge Pharmacy", tour: "T-02" }, { id: "FP-1004262", customer: "Pellham Hardware", tour: "T-03" }, { id: "FP-1004275", customer: "Oakridge Pharmacy", tour: "T-03" }, ]; export default function Selection() { const t = useTable(SHIPMENTS, { rowKey: (s) => s.id }); const { Table, Column } = t; const inTheSet = t.filtered.filter((s) => t.selection.isSelected(s.id)).length; return (
{t.selection.count} selected, {inTheSet} of them in the filtered set.
); } ``` ##### Hold the selection yourself Make it with [`useTableSelection`](#/api/useTableSelection) and pass it to [`useTable`](#/api/useTable) as `selection`: the application counts it, fills it and clears it from controls of its own. ```tsx import { Button, Stack, Text } from "@umriss-ui/core"; import { useTable, useTableSelection } from "@umriss-ui/table"; interface Shipment { id: string; customer: string; tour: string; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", customer: "Holloway Garden Supplies", tour: "T-01" }, { id: "FP-1004223", customer: "Oakridge Pharmacy", tour: "T-01" }, { id: "FP-1004236", customer: "Brixley Cycles", tour: "T-02" }, { id: "FP-1004249", customer: "Oakridge Pharmacy", tour: "T-02" }, { id: "FP-1004262", customer: "Pellham Hardware", tour: "T-03" }, ]; const IDS = SHIPMENTS.map((s) => s.id); const OAKRIDGE = SHIPMENTS.filter((s) => s.customer === "Oakridge Pharmacy").map((s) => s.id); export default function HoldTheSelectionYourself() { const selection = useTableSelection(IDS); const { Table, Column } = useTable(SHIPMENTS, { rowKey: (s) => s.id, selection }); return ( {selection.count} of {SHIPMENTS.length} to load
); } ``` #### When to use something else - Actions that act on the ticked rows → RowActions #### Keyboard The keys of [First table](#/first-table/keyboard-first-table), [Checkbox](https://romanhaendler.github.io/umriss-ui/core/checkbox/#keyboard-checkbox) and [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input) apply here. #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`selectable`](#/first-table/TableProps-selectable) | `boolean` | — | Adds the selection column. "All" means: the filtered set. | | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | | [`selection`](#/first-table/TableOptions-selection) | [`TableSelection`](#/first-table/type-TableSelection)`` | — | A selection from outside instead of its own – when the application holds it. | From `TableSnapshot` — the full table stands on [First table](#/first-table/type-TableSnapshot). | Prop | Type | Default | Description | |---|---|---|---| | [`filtered`](#/first-table/TableSnapshot-filtered) *required* | `readonly Z[]` | — | The filtered set: what the pre-filter admits and search and filters leave, across all pages. | | [`selection`](#/first-table/TableSnapshot-selection) *required* | [`TableSelection`](#/first-table/type-TableSelection)`` | — | The selection – its own or the one handed in through `selection`. | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | From `SearchProps` — the full table stands on [Search](#/search/type-SearchProps). | Prop | Type | Default | Description | |---|---|---|---| | [`placeholder`](#/search/SearchProps-placeholder) | `string` | the wording's "Search …" | The placeholder in the field. | From `ToolbarProps` — the full table stands on [Toolbar](#/toolbar/type-ToolbarProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/toolbar/ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. | #### Known limits - In [server mode](#/server-mode) “select all” selects the page the table holds. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### Row appearance How rows look: dense or regular, marked by their state, and what stands while rows load, fail or do not exist. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/row-appearance/ A row's mark is the application's stylesheet at work; say the same in a word in a column too, so the colour is never the only sign. #### Examples ##### Density `density="compact"` sets one table; an application that wants it dense everywhere says so once at the `UmrissProvider`. ```tsx import { UmrissProvider, Stack, Text } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; interface Service { name: string; team: string; p95: number; } const SERVICES: Service[] = [ { name: "Checkout", team: "Payments", p95: 412 }, { name: "Billing", team: "Payments", p95: 238 }, { name: "Sign-in", team: "Identity", p95: 141 }, ]; function Services({ caption, density }: { caption: string; density?: "regular" | "compact" }) { const { Table, Column } = useTable(SERVICES, { rowKey: (s) => s.name }); return ( {caption}
); } export default function Density() { return ( ); } ``` ##### Mark rows by their state `rowProps` gives a row classes and data attributes; the application's stylesheet draws them, and a word in a column says the same. ```tsx import { useTable } from "@umriss-ui/table"; interface Invoice { id: string; supplier: string; due: Date; overdue: boolean; } const INVOICES: Invoice[] = [ { id: "INV-26-0318", supplier: "Brandlow Office Supply", due: new Date(2026, 3, 15), overdue: false }, { id: "INV-26-0309", supplier: "Nimbrel Software", due: new Date(2026, 3, 8), overdue: false }, { id: "INV-26-0226", supplier: "Corrin Travel", due: new Date(2026, 2, 12), overdue: true }, { id: "INV-26-0221", supplier: "Stellbrook Consulting", due: new Date(2026, 2, 16), overdue: true }, ]; const STYLE = ` .invoice-list tr[data-overdue] > th { box-shadow: inset 3px 0 0 var(--u-color-warning); } `; export default function StylingRows() { const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id }); return ( <> ({ "data-overdue": i.overdue ? "" : undefined })}>
); } ``` ##### Show loading, empty, failed and no match `loading` shows placeholder rows where there are none yet, and dims the rows that stand while new ones are on their way; `empty` what stands without rows, a failed load too; that nothing matches the table recognises itself. ```tsx import type { ReactNode } from "react"; import { Alert, Button, EmptyState, Stack } from "@umriss-ui/core"; import { Search, Toolbar, useTable } from "@umriss-ui/table"; interface Incident { id: string; title: string; severity: string; } const INCIDENTS: Incident[] = [ { id: "INC-1048", title: "Checkout slow, card payments time out", severity: "SEV1" }, { id: "INC-1047", title: "Webhook deliveries delayed", severity: "SEV3" }, { id: "INC-1046", title: "Thumbnails missing for new uploads", severity: "SEV2" }, ]; const NONE: Incident[] = []; function Incidents({ rows, label, ...props }: { rows: Incident[]; label: string; loading?: boolean; empty?: ReactNode; search?: string }) { const { Table, Column } = useTable(rows, { rowKey: (i) => i.id, initialView: props.search ? { search: props.search } : undefined }); return ( {props.search !== undefined && ( )}
); } export default function States() { return ( } /> Try again}> The status service did not answer within 10 seconds. } /> ); } ``` ##### Stripes across a wide table `striped` shades every other row, so the eye keeps a long row on its line across eight columns, and on a phone while it scrolls sideways. ```tsx import { useTable } from "@umriss-ui/table"; interface Shipment { id: string; customer: string; street: string; city: string; tour: string; driver: string; parcels: number; weight: number; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", customer: "Holloway Garden Supplies", street: "14 Orchard Lane", city: "Ashford", tour: "T-01", driver: "Tomasz Nowak", parcels: 6, weight: 1140 }, { id: "FP-1004223", customer: "Oakridge Pharmacy", street: "2 Station Road", city: "Wye", tour: "T-01", driver: "Tomasz Nowak", parcels: 2, weight: 420 }, { id: "FP-1004236", customer: "Brixley Cycles", street: "88 High Street", city: "Charing", tour: "T-02", driver: "Leila Haddad", parcels: 4, weight: 310 }, { id: "FP-1004249", customer: "Pellham Hardware", street: "5 Forge Yard", city: "Lenham", tour: "T-02", driver: "Leila Haddad", parcels: 9, weight: 1620 }, { id: "FP-1004262", customer: "Corrin Travel", street: "31 Bank Street", city: "Ashford", tour: "T-03", driver: "Jonas Keller", parcels: 1, weight: 12 }, { id: "FP-1004275", customer: "Nimbrel Software", street: "7 Kennington Road", city: "Willesborough", tour: "T-03", driver: "Jonas Keller", parcels: 3, weight: 95 }, ]; export default function StripesAcrossAWideTable() { const { Table, Column } = useTable(SHIPMENTS, { rowKey: (s) => s.id }); return (
); } ``` #### Keyboard The keys of [First table](#/first-table/keyboard-first-table), [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input) and [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button) apply here. #### Accessibility While `loading` is set the table is `aria-busy`: a screen reader waits for the rows instead of reading the placeholders. Rows that stand while new ones load stay readable. What stands in `empty` is the application's, and it is read where it stands. A failed load that must be heard on arrival goes there as core's `Alert` with `tone="danger"`, which announces itself. That nothing matches a search or a filter the table says itself. #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`stickyRowHeader`](#/first-table/TableProps-stickyRowHeader) | `boolean` | — | Keeps the row header column visible while scrolling sideways - the same as `pin="start"` on the row header. | | [`striped`](#/first-table/TableProps-striped) | `boolean` | — | Discreet zebra stripes. | | [`density`](#/first-table/TableProps-density) | `"regular" \| "compact"` | the density of the `UmrissProvider`, else `"regular"` | How tightly the rows stand. | | [`empty`](#/first-table/TableProps-empty) | `ReactNode` | — | What stands in the body when there are no rows. Empty because of a filter the table shows itself – with the way back. | | [`loading`](#/first-table/TableProps-loading) | `boolean` | — | Shows placeholder rows instead of the rows. | | [`rowProps`](#/first-table/TableProps-rowProps) | `(row: Z) => `[`RowAttributes`](#/first-table/type-RowAttributes) | — | Classes and data attributes per row. | | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | ###### Styling | Prop | Type | Default | Description | |---|---|---|---| | [`className`](#/first-table/TableProps-className) | `string` | — | Classes on the outer frame of the table. | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | | [`initialView`](#/first-table/TableOptions-initialView) | [`TableView`](#/first-table/type-TableView) | — | The view to start from, read once when the hook first runs; a different value on a later render is ignored, as React ignores a changed `defaultValue`. To go to a view later, call `setView`. The table remembers none: where a view is kept is the application's decision. Names no column carries fall out. | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | | [`format`](#/column/FieldColumn-format) | [`FormatFor`](#/column/type-FormatFor)`` | — | A standard presentation by name: `"percent"`, `"count"` or `{ decimals }` for numbers, `"date"`, `"time"` or `"dateTime"` for points in time. Alignment, sorting, export and footer stay with the value. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | From `SearchProps` — the full table stands on [Search](#/search/type-SearchProps). | Prop | Type | Default | Description | |---|---|---|---| | [`placeholder`](#/search/SearchProps-placeholder) | `string` | the wording's "Search …" | The placeholder in the field. | From `ToolbarProps` — the full table stands on [Toolbar](#/toolbar/type-ToolbarProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/toolbar/ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. | ### RowDetail More about a row, opened beneath it on demand (also called an expandable or master-detail row). Reach for it when a row needs facts, notes or a small table of its own that would crowd the columns. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/rowdetail/ The table puts an expander at the start of each row, named after the row – “Expand FP-1004210”. Several rows can be open at once, and a row stays open by its key: a search or a filter that hides it for a moment does not close it. The detail spans all visible columns. Its function is called while the table renders, so a detail that needs a hook – a table of its own – goes into a component. #### Examples ##### Open a detail Put a `RowDetail` in the table and each row gets an expander; its function receives the row and returns what stands beneath it. ```tsx import { Grid, Stack, Text } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; interface Shipment { id: string; customer: string; address: string; window: string; weight: number; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", customer: "Holloway Garden Supplies", address: "12 Orchard Lane, Ashcombe", window: "08:00–10:00", weight: 23 }, { id: "FP-1004223", customer: "Brixley Cycles", address: "4 Station Road, Brixley", window: "08:00–10:00", weight: 14 }, { id: "FP-1004236", customer: "Pellham Hardware", address: "88 High Street, Pellham", window: "10:00–12:00", weight: 4 }, ]; function Fact({ name, value }: { name: string; value: string }) { return ( {name} {value} ); } export default function OpenADetail() { const { Table, Column, RowDetail } = useTable(SHIPMENTS, { rowKey: (s) => s.id }); return ( {(s) => ( )}
); } ``` ##### Keep rows open while searching Open two rows, search them away and back: a row stays open by its key, so the details return as they were. ```tsx import { Text } from "@umriss-ui/core"; import { Search, Toolbar, useTable } from "@umriss-ui/table"; interface Shipment { id: string; customer: string; note: string; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", customer: "Holloway Garden Supplies", note: "Leave at the side gate if closed." }, { id: "FP-1004223", customer: "Brixley Cycles", note: "Failed attempt at 08:40 – the shop opens at 09:30." }, { id: "FP-1004236", customer: "Pellham Hardware", note: "Two pallets, tail lift needed." }, { id: "FP-1004249", customer: "Ashcombe Dental", note: "Reception on the first floor." }, ]; export default function KeepRowsOpen() { const { Table, Column, RowDetail } = useTable(SHIPMENTS, { rowKey: (s) => s.id }); return ( {(s) => {s.note}}
); } ``` ##### Show the stops of a tour A detail can hold a table of its own – give it a component, since [`useTable`](#/api/useTable) is a hook and the detail a plain function. ```tsx import { 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; 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)); const time = (t: number) => new Date(t).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); function Stops({ tour }: { tour: Tour }) { const { Table, Column } = useTable(tour.stops, { rowKey: (s) => s.id }); return ( `${time(s.window[0])}–${time(s.window[1])}`} /> time(s.arrival)} />
); } export default function StopsOfATour() { const { Table, Column, RowDetail } = useTable(TOURS, { rowKey: (t) => t.id }); return ( DRIVERS.find((d) => d.id === t.driver)?.name ?? t.driver} /> t.stops.length} /> {(t) => }
); } ``` ##### Open a detail from outside the table `t.toggleRow` opens a row's detail by its key, or closes it; `t.expanded` holds the keys open now, whoever opened them - the expander or a control of the application's own. ```tsx import { Button, Grid, Stack, Text } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; interface Shipment { id: string; customer: string; address: string; window: string; attempt: string | null; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", customer: "Holloway Garden Supplies", address: "12 Orchard Lane, Ashcombe", window: "08:00–10:00", attempt: null }, { id: "FP-1004223", customer: "Oakridge Pharmacy", address: "3 Market Square, Oakridge", window: "08:00–10:00", attempt: "Nobody in at 09:12, card left" }, { id: "FP-1004236", customer: "Brixley Cycles", address: "4 Station Road, Brixley", window: "10:00–12:00", attempt: null }, { id: "FP-1004262", customer: "Pellham Hardware", address: "88 High Street, Pellham", window: "10:00–12:00", attempt: "Shop closed at 10:40" }, ]; const FAILED = SHIPMENTS.filter((s) => s.attempt !== null); export default function OpenADetailFromOutside() { const t = useTable(SHIPMENTS, { rowKey: (s) => s.id }); const { Table, Column, RowDetail } = t; const closedFailures = FAILED.filter((s) => !t.expanded.includes(s.id)); return ( {FAILED.length} failed attempts {(s) => ( {s.address} {s.attempt ?? "No attempt yet"} )}
); } ``` #### Keyboard | Key | Action | |---|---| | `Enter` | On an expander: open or close the row's detail. | | `Space` | On an expander: open or close the row's detail. | #### API ##### `RowDetailProps` | Prop | Type | Default | Description | |---|---|---|---| | `children` *required* | `(row: Z) => ReactNode` | — | What stands underneath an expanded row.
Shown in: [Open a detail](#/rowdetail/open-a-detail), [Keep rows open while searching](#/rowdetail/keep-rows-open-while-searching), [Show the stops of a tour](#/rowdetail/show-the-stops-of-a-tour) and 5 more | ### RowActions Buttons at the end of each row for what the user does to that row – open, approve, reject. Mark one as a bulk action and it also acts on every ticked row at once. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/rowactions/ Up to two actions stand in the row as buttons; from three on, all of them move into one menu per row, so the column keeps its width and no action changes place when another is added. Each is read out with the row's name – “Open: INV-26-0318”. The buttons are always visible: quiet in the secondary colour at rest, in the accent while the pointer is over the row or the focus inside it. A `bulk` action always receives a list – one row when pressed at the row, the selection within the filtered set when pressed in the toolbar. One function, and three rows are one confirmation, not three. #### Examples ##### Two actions Put `Action`s into `RowActions`; `onSelect` receives the row, and each button is read out with the row's name, as in “Open: INV-26-0318”. ```tsx import { useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; interface Invoice { id: string; supplier: string; } const INVOICES: Invoice[] = [ { id: "INV-26-0318", supplier: "Brandlow Office Supply" }, { id: "INV-26-0317", supplier: "Kettering & Shaw Events" }, { id: "INV-26-0309", supplier: "Nimbrel Software" }, ]; export default function TwoActions() { const [last, setLast] = useState("No action yet."); const { Table, Column, RowActions, Action } = useTable(INVOICES, { rowKey: (i) => i.id }); return ( setLast(`${i.id} opened`)}>Open setLast(`${i.id} forwarded`)}>Forward
{last}
); } ``` ##### Act on a selection Mark an action `bulk` and it receives a list: one row at the row, the selection in the `Toolbar` while rows are ticked. ```tsx import { useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { Toolbar, useTable } from "@umriss-ui/table"; interface Invoice { id: string; supplier: string; costCentre: string; } const INVOICES: Invoice[] = [ { id: "INV-26-0318", supplier: "Brandlow Office Supply", costCentre: "Facilities" }, { id: "INV-26-0317", supplier: "Kettering & Shaw Events", costCentre: "Marketing" }, { id: "INV-26-0315", supplier: "Harlow Print Works", costCentre: "Marketing" }, { id: "INV-26-0312", supplier: "Corrin Travel", costCentre: "Sales" }, ]; export default function ActOnASelection() { const [last, setLast] = useState("Nothing approved yet."); const { Table, Column, RowActions, Action } = useTable(INVOICES, { rowKey: (i) => i.id }); return ( setLast(`Approved: ${list.map((i) => i.id).join(", ")}`)}> Approve
{last}
); } ``` ##### More than two actions From three actions on, all of them move into one menu per row, so the column keeps its width; `tone="danger"` marks the destructive one. ```tsx import { useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; interface Invoice { id: string; supplier: string; } const INVOICES: Invoice[] = [ { id: "INV-26-0318", supplier: "Brandlow Office Supply" }, { id: "INV-26-0317", supplier: "Kettering & Shaw Events" }, { id: "INV-26-0309", supplier: "Nimbrel Software" }, ]; export default function MoreThanTwo() { const [last, setLast] = useState("No action yet."); const { Table, Column, RowActions, Action } = useTable(INVOICES, { rowKey: (i) => i.id }); return ( setLast(`${i.id} opened`)}>Open setLast(`${i.id} forwarded`)}>Forward setLast(`${i.id} rejected`)}> Reject
{last}
); } ``` #### Keyboard | Key | Action | |---|---| | `Enter / Space` | On an action: run it. On the row's menu button: open the menu. | | `↓ / ↑` | In the menu: the next or previous entry, wrapping around. | | `Home / End` | In the menu: the first or last entry. | | `Escape` | Close the menu; the focus returns to its button. | | `Tab` | Close the menu and move on. | #### Accessibility Each action is a button named after itself and the row - “Open: INV-26-0318” - so a reader that lists the buttons tells the rows apart. From three actions on, the row's menu button is named “Actions: INV-26-0318”. The row's name is its `rowHeader` cell. What an action does is the application's to say: a result that should be heard - a row approved, a row gone - goes to a status region or core's `useToast`. #### API ##### `RowActionsProps` | Prop | Type | Default | Description | |---|---|---|---| | `children` *required* | `ReactNode` | — | The row's `Action`s.
Shown in: [Two actions](#/rowactions/two-actions), [Act on a selection](#/rowactions/act-on-a-selection), [More than two actions](#/rowactions/more-than-two) and 7 more | | `pin` | `boolean` | `true` | Whether the actions column sticks at the end of the row while the table scrolls sideways. `false` leaves it in the flow, for a place too narrow for a pinned block - the application decides, by its own measure. A column pinned to the end takes the actions with it regardless.
Shown in: [Find a late shipment (Scenarios)](#/scenarios/find-a-late-shipment) | ##### `ActionProps` | Prop | Type | Default | Description | |---|---|---|---| | `children` *required* | `string` | — | The label – text, because it is read out together with the row's name.
Shown in: [Two actions](#/rowactions/two-actions), [Act on a selection](#/rowactions/act-on-a-selection), [More than two actions](#/rowactions/more-than-two) and 7 more | | `tone` | `"default" \| "danger"` | — | `"danger"` for an action that destroys something – that is how it appears in the menu and in the table toolbar.
Shown in: [More than two actions](#/rowactions/more-than-two), [Find a late shipment (Scenarios)](#/scenarios/find-a-late-shipment) | | `bulk` | `false \| true` | — | Acts on a list: at the row on a list made from it, in the table toolbar on the selection within the filtered set.
Shown in: [Act on a selection](#/rowactions/act-on-a-selection), [Select a whole group (Grouping)](#/grouping/a-whole-group-selected), [Read a day's deliveries by depot (Grouping)](#/grouping/the-dispatch-report) and 2 more | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | `onSelect` *required* | `((row: Z) => void) \| ((rows: readonly Z[]) => void)` | — | Gets the row the action was triggered on. With `bulk` a list: at the row one made from it, in the table toolbar the selection.
Shown in: [Two actions](#/rowactions/two-actions), [Act on a selection](#/rowactions/act-on-a-selection), [More than two actions](#/rowactions/more-than-two) and 7 more | ## Around the table The bar above the table, what it holds, and the view a table can hand back. ### Toolbar The bar above the table that holds its search, column menu and export, shows how many rows match, and carries the bulk actions while rows are ticked (also called a table header bar). ```ts import { Toolbar } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/toolbar/ It stands above the table wherever it stands in the JSX. On the left go the parts you put into it, then the conditions of the column filters; on the right the count of matches and “Reset” while something restricts, and the selection with its bulk actions while rows are ticked. A table with a search or a column filter puts up a toolbar of its own when none stands. Outside the table, pass `of` to the toolbar and to each part in it, and place it before the table – after it, it registers only after the first paint. #### Examples ##### Search, arrange and export Put the parts into a `Toolbar` inside the table; tick rows and the bulk actions and the selection count join it on the right. ```tsx import { useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { ColumnMenu, Export, Search, Toolbar, useTable } from "@umriss-ui/table"; interface Incident { id: string; title: string; service: string; severity: "SEV1" | "SEV2" | "SEV3"; } const INCIDENTS: Incident[] = [ { id: "INC-1048", title: "Checkout slow, card payments time out", service: "Checkout", severity: "SEV1" }, { id: "INC-1047", title: "Webhook deliveries delayed", service: "Webhooks", severity: "SEV3" }, { id: "INC-1046", title: "Thumbnails missing for new uploads", service: "Image service", severity: "SEV2" }, { id: "INC-1045", title: "Sign-in codes arrive late", service: "Sign-in", severity: "SEV2" }, ]; export default function SearchArrangeAndExport() { const [last, setLast] = useState("Nothing closed yet."); const { Table, Column, RowActions, Action } = useTable(INCIDENTS, { rowKey: (i) => i.id }); return ( setLast(`Closed: ${list.map((i) => i.id).join(", ")}`)}> Close
{last}
); } ``` ##### Place it outside the table Keep what [`useTable`](#/api/useTable) returns and pass it as `of` to the toolbar and to each part in it; put the toolbar before the table. ```tsx import { Stack } from "@umriss-ui/core"; import { Export, Search, Toolbar, useTable } from "@umriss-ui/table"; interface Incident { id: string; title: string; } const INCIDENTS: Incident[] = [ { id: "INC-1048", title: "Checkout slow, card payments time out" }, { id: "INC-1047", title: "Webhook deliveries delayed" }, { id: "INC-1046", title: "Thumbnails missing for new uploads" }, ]; export default function PlaceItOutside() { const t = useTable(INCIDENTS, { rowKey: (i) => i.id }); const { Table, Column } = t; return (
); } ``` ##### Parts outside a toolbar Each part takes the table as `of` and stands where you put it: here in a card header at `size="md"`, the card's height, with the pager above the rows. ```tsx import { Card, CardBody, CardHeader, Stack } from "@umriss-ui/core"; import { ColumnMenu, Export, Pagination, Search, useTable } from "@umriss-ui/table"; /* Outside a toolbar a part is `sm` unless it says otherwise. The pager stands before the table: placed after it, it registers only after the first frame. */ interface Incident { id: string; title: string; service: string; } const INCIDENTS: Incident[] = [ { id: "INC-1048", title: "Checkout slow, card payments time out", service: "Checkout" }, { id: "INC-1047", title: "Webhook deliveries delayed", service: "Webhooks" }, { id: "INC-1046", title: "Thumbnails missing for new uploads", service: "Images" }, { id: "INC-1045", title: "Search returns stale prices", service: "Search" }, { id: "INC-1044", title: "Password reset mails late", service: "Identity" }, { id: "INC-1043", title: "Nightly report missing a day", service: "Reporting" }, ]; export default function PartsOutsideAToolbar() { const t = useTable(INCIDENTS, { rowKey: (i) => i.id, pageSize: 4 }); const { Table, Column } = t; return ( } />
); } ``` #### Keyboard The keys of [First table](#/first-table/keyboard-first-table), [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input), [ColumnMenu](#/columnmenu/keyboard-columnmenu), [RowActions](#/rowactions/keyboard-rowactions), [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button) and [Checkbox](https://romanhaendler.github.io/umriss-ui/core/checkbox/#keyboard-checkbox) apply here. #### Accessibility The count of matches is a status region. It stays empty while nothing restricts the rows, and a screen reader hears “3 of 12” when a search or a filter narrows them, and the new count on every change after. The parts the table puts in name themselves: the search field “Search table” unless its `aria-label` says otherwise, “Columns”, “Export”. A control of your own in the bar needs a label of its own, as anywhere. #### API ##### `ToolbarProps` | Prop | Type | Default | Description | |---|---|---|---| | `children` | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions.
Shown in: [Search, arrange and export](#/toolbar/search-arrange-and-export), [Place it outside the table](#/toolbar/place-it-outside), [Set wording and formats once (Provider)](#/provider/provider) and 37 more | | `size` | [`ToolbarSize`](#type-ToolbarSize)
`"sm" \| "md"` | `"sm"` | The size of every control the table puts into it - search, column menu, export, "reset", the bulk actions -: `sm` or `md`. A control of one's own takes the same size by its own prop.
Shown in: [A larger toolbar (Toolbar controls)](#/toolbar-controls/a-larger-toolbar), [A dispatcher's bar (Toolbar controls)](#/toolbar-controls/a-dispatcher-s-bar) | | `of` | [`TableRef`](#type-TableRef) | — | The table, when the table toolbar does not stand inside it. If it then stands behind the table in the tree, it registers only after the first frame – until then a table with a search or a column filter shows its own toolbar. Placed before the table, there is no such change.
Shown in: [Place it outside the table](#/toolbar/place-it-outside), [Review cost centres against budget (Scenarios)](#/scenarios/review-cost-centres) | ###### Styling | Prop | Type | Default | Description | |---|---|---|---| | `className` | `string` | — | Classes on the table toolbar. | ##### Types on this page ###### `ToolbarSize` The size of a toolbar's controls. ```ts type ToolbarSize = "sm" | "md"; ``` ###### `TableRef` What a part without a row type needs from a table – for `of`. ```ts type TableRef = Omit, "setFilter" | "conditionOf"> & { readonly Table: unknown; /** Without a row kind: any id, any condition. */ setFilter(column: string, condition: unknown): void; }; ``` ### Toolbar controls Put controls of your own into the toolbar - a multiselect, a select, a combobox - and let them stand level with the table's: one size for the whole bar, and a row filter behind each control. ```ts import { Toolbar, rowFilter } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/toolbar-controls/ `Toolbar` takes `size`, `sm` or `md`, and the parts the table puts into it follow: search, column menu, export, “Reset” and the bulk actions. A part says its own size with its own `size`. A control from @umriss-ui/core that you put in takes the toolbar's size as well, without a word - the toolbar is a `ControlSizeProvider`, as @umriss-ui/core's Sizes page shows - and keeps its own `size` if it says one. No widths to set: a field from @umriss-ui/core is its natural width in the bar - a count of characters of its type, or its `chars` - and never what it shows, so choosing in a multiselect moves nothing beside it. A control of your own restricts the table through a [row filter](#/filter): it sets the condition with `t.setFilter(filter, condition)` and reads it with `t.conditionOf(filter)`, so “Reset”, the count of matches and the view include it. #### Examples ##### Controls of your own at the toolbar's size The toolbar is `sm` unless it says otherwise, and a control from @umriss-ui/core put into it takes that size without a word - level with the search, at its natural width, whatever it shows. Behind each control a row filter. ```tsx import { MultiSelect, Select } 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; }), ); type Status = Shipment["status"]; const tours = rowFilter({ id: "tours", label: "Tours", matches: (shipment: Shipment, chosen: readonly string[]) => chosen.includes(shipment.tour), }); const status = rowFilter({ id: "status", label: "Status", matches: (shipment: Shipment, wanted: Status) => shipment.status === wanted, }); const TOUR_OPTIONS = [...new Set(SHIPMENTS.map((s) => s.tour))].map((tour) => ({ value: tour, label: tour })); export default function ControlsAtTheToolbarsSize() { const t = useTable(SHIPMENTS, { rowKey: (s) => s.id, rowFilters: [tours, status] }); const { Table, Column } = t; return ( t.setFilter(tours, next.length > 0 ? next : null)} />
); } ``` ##### A larger toolbar `size="md"` on the toolbar, and search, column menu, export, “Reset” and the multiselect of one's own follow. One size for the whole bar, said once. ```tsx import { MultiSelect } from "@umriss-ui/core"; import { ColumnMenu, Export, 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; }), ); 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 ALargerToolbar() { const t = useTable(SHIPMENTS, { rowKey: (s) => s.id, rowFilters: [tours] }); const { Table, Column } = t; return ( t.setFilter(tours, next.length > 0 ? next : null)} />
); } ``` ##### A dispatcher's bar Three controls of one's own and the table's parts in one `md` bar, a quick filter beside the table - and every condition counted, reset and kept in the view alike. Only the one set from outside describes itself as a chip: the others show in their control. ```tsx import { Button, Combobox, MultiSelect, Select, Stack } from "@umriss-ui/core"; import { ColumnMenu, Export, 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; }), ); type Status = Shipment["status"]; const tours = rowFilter({ id: "tours", label: "Tours", matches: (shipment: Shipment, chosen: readonly string[]) => chosen.includes(shipment.tour), }); const status = rowFilter({ id: "status", label: "Status", matches: (shipment: Shipment, wanted: Status) => shipment.status === wanted, }); const customer = rowFilter({ id: "customer", label: "Customer", matches: (shipment: Shipment, name: string) => shipment.customer === name, }); /* Set from a button beside the table, not from a control in the bar - so it says itself in the toolbar, where its cross lifts it. */ const heavy = rowFilter({ id: "heavy", label: "Weight", matches: (shipment: Shipment, atLeast: number) => shipment.weight >= atLeast, describe: (atLeast) => `from ${atLeast} kg`, }); const TOUR_OPTIONS = [...new Set(SHIPMENTS.map((s) => s.tour))].map((tour) => ({ value: tour, label: tour })); const CUSTOMER_OPTIONS = [...new Set(SHIPMENTS.map((s) => s.customer))] .sort() .map((name) => ({ value: name, label: name })); export default function ADispatchersBar() { const t = useTable(SHIPMENTS, { rowKey: (s) => s.id, rowFilters: [tours, status, customer, heavy], initialView: { conditions: { heavy: 20 } }, }); const { Table, Column } = t; return ( t.setFilter(tours, next.length > 0 ? next : null)} /> t.setFilter(customer, name)} />
); } ``` #### When to use something else - A filter on a column the table offers itself, in its header → Filter #### Keyboard The keys of [First table](#/first-table/keyboard-first-table), [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input), [ColumnMenu](#/columnmenu/keyboard-columnmenu), [Select](https://romanhaendler.github.io/umriss-ui/core/select/#keyboard-select), [MultiSelect](https://romanhaendler.github.io/umriss-ui/core/multiselect/#keyboard-multiselect), [Combobox](https://romanhaendler.github.io/umriss-ui/core/combobox/#keyboard-combobox) and [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button) apply here. #### API ##### `ToolbarProps` | Prop | Type | Default | Description | |---|---|---|---| | `children` | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions.
Shown in: [Controls of your own at the toolbar's size](#/toolbar-controls/controls-at-the-toolbars-size), [A larger toolbar](#/toolbar-controls/a-larger-toolbar), [A dispatcher's bar](#/toolbar-controls/a-dispatcher-s-bar) and 37 more | | `size` | [`ToolbarSize`](#type-ToolbarSize)
`"sm" \| "md"` | `"sm"` | The size of every control the table puts into it - search, column menu, export, "reset", the bulk actions -: `sm` or `md`. A control of one's own takes the same size by its own prop.
Shown in: [A larger toolbar](#/toolbar-controls/a-larger-toolbar), [A dispatcher's bar](#/toolbar-controls/a-dispatcher-s-bar) | | `of` | [`TableRef`](#type-TableRef) | — | The table, when the table toolbar does not stand inside it. If it then stands behind the table in the tree, it registers only after the first frame – until then a table with a search or a column filter shows its own toolbar. Placed before the table, there is no such change.
Shown in: [Place it outside the table (Toolbar)](#/toolbar/place-it-outside), [Review cost centres against budget (Scenarios)](#/scenarios/review-cost-centres) | ###### Styling | Prop | Type | Default | Description | |---|---|---|---| | `className` | `string` | — | Classes on the table toolbar. | ##### `RowFilter` | Prop | Type | Default | Description | |---|---|---|---| | `id` *required* | `string` | — | 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: [Controls of your own at the toolbar's size](#/toolbar-controls/controls-at-the-toolbars-size), [A larger toolbar](#/toolbar-controls/a-larger-toolbar), [A dispatcher's bar](#/toolbar-controls/a-dispatcher-s-bar) and 3 more | | `label` *required* | `string` | — | What it is called: before the text of its chip, and in the chip's accessible names.
Shown in: [Controls of your own at the toolbar's size](#/toolbar-controls/controls-at-the-toolbars-size), [A larger toolbar](#/toolbar-controls/a-larger-toolbar), [A dispatcher's bar](#/toolbar-controls/a-dispatcher-s-bar) and 3 more | | `matches` *required* | `(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: [Controls of your own at the toolbar's size](#/toolbar-controls/controls-at-the-toolbars-size), [A larger toolbar](#/toolbar-controls/a-larger-toolbar), [A dispatcher's bar](#/toolbar-controls/a-dispatcher-s-bar) 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: [A dispatcher's bar](#/toolbar-controls/a-dispatcher-s-bar), [Ask a question over several fields (Filter)](#/filter/a-question-over-several-fields) | ##### Types on this page ###### `ToolbarSize` The size of a toolbar's controls. ```ts type ToolbarSize = "sm" | "md"; ``` ###### `TableRef` What a part without a row type needs from a table – for `of`. ```ts type TableRef = Omit, "setFilter" | "conditionOf"> & { readonly Table: unknown; /** Without a row kind: any id, any condition. */ setFilter(column: string, condition: unknown): void; }; ``` ### ColumnMenu A menu in which the user shows, hides, reorders and pins the table's columns and picks a grouping (also called a column chooser). Offer it where the table has more columns than everyone needs. ```ts import { ColumnMenu } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/columnmenu/ Every change applies to header, body and footer together. The row header cannot be hidden. A column moves only within its block – pinned at the start, unpinned, pinned at the end – and after a move the focus stays on the button that was pressed. Where the table can be grouped, the menu offers its groupable columns too, up to three levels ([Grouping](#/grouping)). #### Examples ##### Show, hide and arrange Put a `ColumnMenu` into the toolbar; hiding or moving a column changes header, body and footer together. ```tsx import { ColumnMenu, Toolbar, useTable } from "@umriss-ui/table"; interface CostCentre { name: string; owner: string; budget: number; actual: number; forecast: number; } /* Thousands of euros, for March. */ const CENTRES: CostCentre[] = [ { name: "Sales", owner: "Helen Marsh", budget: 142, actual: 138, forecast: 144 }, { name: "Marketing", owner: "Rafael Ortiz", budget: 68, actual: 79, forecast: 83 }, { name: "Engineering", owner: "Anika Sørensen", budget: 188, actual: 181, forecast: 186 }, ]; export default function ShowHideAndArrange() { const { Table, Column } = useTable(CENTRES, { rowKey: (c) => c.name }); return (
); } ``` ##### Pin a column The pin keys in the menu hold a column at the start or the end while the months scroll sideways – try pinning the year. ```tsx import { ColumnMenu, Toolbar, useTable } from "@umriss-ui/table"; interface CostCentre { name: string; months: number[]; } const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; /* Thousands of euros per month. */ const CENTRES: CostCentre[] = [ { name: "Sales", months: [141, 139, 138, 144, 142, 140, 136, 131, 143, 145, 147, 150] }, { name: "Marketing", months: [66, 71, 79, 83, 70, 68, 64, 60, 74, 77, 81, 90] }, { name: "Customer service", months: [60, 61, 59, 62, 61, 63, 60, 58, 61, 62, 60, 64] }, ]; export default function PinAColumn() { const { Table, Column } = useTable(CENTRES, { rowKey: (c) => c.name }); return ( {MONTHS.map((month, i) => ( c.months[i] ?? null} width={80} /> ))} c.months.reduce((a, b) => a + b, 0)} width={90} />
); } ``` #### Keyboard | Key | Action | |---|---| | `Enter / Space` | On “Columns”: open or close the menu. Inside: tick a column, move it or pin it. | | `Tab` | Move through the columns' boxes and buttons. | | `Escape` | Close the menu; the focus returns to “Columns”. | #### API ##### `ColumnMenuProps` | Prop | Type | Default | Description | |---|---|---|---| | `size` | [`ToolbarSize`](#type-ToolbarSize)
`"sm" \| "md"` | the size of the toolbar it stands in, `"sm"` outside one | The size of the part: `sm` or `md`.
Shown in: [Parts outside a toolbar (Toolbar)](#/toolbar/parts-outside-a-toolbar) | | `of` | [`TableRef`](#type-TableRef) | — | The table, when the part does not stand inside it: the return value of `useTable`.
Shown in: [Parts outside a toolbar (Toolbar)](#/toolbar/parts-outside-a-toolbar) | ##### Types on this page ###### `ToolbarSize` The size of a toolbar's controls. ```ts type ToolbarSize = "sm" | "md"; ``` ###### `TableRef` What a part without a row type needs from a table – for `of`. ```ts type TableRef = Omit, "setFilter" | "conditionOf"> & { readonly Table: unknown; /** Without a row kind: any id, any condition. */ setFilter(column: string, condition: unknown): void; }; ``` ### Export A button that writes the rows the user sees into a CSV file for a spreadsheet (also called download or export to Excel). Hand the application the text instead where it wants the clipboard or a file of its own. ```ts import { Export } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/export/ It writes the filtered set across all pages, in the visible columns, in the order and sorting on screen – and the values, not their presentation: numbers with the decimal mark of the formats, yes and no from the wording, a point in time as a timestamp, an absent value as an empty field. The separator follows the mark – a comma, or a semicolon where the mark is a comma – and a byte order mark tells the spreadsheet it is UTF-8. English gets commas and a point; the German wording and formats from `@umriss-ui/core/wording/de` get the file the table always wrote, and German wording with English formats gets German words with comma and point – take both halves ([ADR-0061](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0061-the-csv-follows-the-language-in-use.md)). A verdict column exports its value, not the verdict. In [server mode](#/server-mode) the table holds one page of the server's, so it exports that page, and the button says so. The same text is `t.asCsv()`, without a button. Ctrl or Cmd with C copies the selected rows – the visible columns under a header line, tab-separated, with the same values and the formats' decimal mark – and in a grid with no selection the Active cell. What was copied flashes once in an accent tint (shown without the fade under reduced motion), and a screen reader is told how many rows were copied, or that the copy failed. Marked text and a focused field keep the browser's own copy; in an open cell editor with nothing marked the key copies the value that stands in the field. `t.selectionAsText()` is the same text for a button of your own, as in the last example. #### Examples ##### Download a file Put an `Export` into the toolbar: the file holds what the user sees – the filtered, sorted rows of every page, in the visible columns. ```tsx import { Export, Search, Toolbar, useTable } from "@umriss-ui/table"; interface Shipment { id: string; customer: string; parcels: number; weight: number; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", customer: "Holloway Garden Supplies", parcels: 3, weight: 23.5 }, { id: "FP-1004223", customer: "Northgate Florists", parcels: 6, weight: 41.25 }, { id: "FP-1004236", customer: "Pellham Hardware", parcels: 1, weight: 4 }, { id: "FP-1004249", customer: "Northbay Books", parcels: 2, weight: 18.4 }, ]; export default function DownloadAFile() { const { Table, Column } = useTable(SHIPMENTS, { rowKey: (s) => s.id }); return (
); } ``` ##### Take the text Pass `onExport` to receive the CSV text instead of a download – for the clipboard or a service; values, not their presentation. ```tsx import { useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { Export, Toolbar, useTable } from "@umriss-ui/table"; interface Shipment { id: string; weight: number; delivered: Date | null; signed: boolean; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", weight: 23.5, delivered: new Date(Date.UTC(2026, 2, 17, 8, 12)), signed: true }, { id: "FP-1004223", weight: 14, delivered: null, signed: false }, ]; export default function TakeTheText() { const [text, setText] = useState("Not exported yet."); const { Table, Column } = useTable(SHIPMENTS, { rowKey: (s) => s.id }); return (
{text}
); } ``` ##### Copy the selection Ctrl or Cmd with C copies the selected rows, tab-separated under a header line, and they flash once; `selectionAsText()` hands the same text to a button of your own. ```tsx import { Button, Stack, Text } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; interface Shipment { id: string; customer: string; weight: number; } const SHIPMENTS: Shipment[] = [ { id: "FP-1004210", customer: "Holloway Garden Supplies", weight: 23.5 }, { id: "FP-1004223", customer: "Northgate Florists", weight: 41.25 }, { id: "FP-1004236", customer: "Pellham Hardware", weight: 4 }, ]; export default function CopyTheSelection() { const t = useTable(SHIPMENTS, { rowKey: (s) => s.id }); const { Table, Column } = t; return ( {t.selection.count} selected
); } ``` #### Keyboard The keys of [First table](#/first-table/keyboard-first-table), [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input) and [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button) apply here. #### API ##### `ExportProps` | Prop | Type | Default | Description | |---|---|---|---| | `filename` | `string` | the wording's "table.csv" | File name of the download.
Shown in: [Download a file](#/export/download-a-file), [Set wording and formats once (Provider)](#/provider/provider), [Search takes the line (Search)](#/search/search-takes-the-line) and 6 more | | `size` | [`ToolbarSize`](#type-ToolbarSize)
`"sm" \| "md"` | the size of the toolbar it stands in, `"sm"` outside one | The size of the part: `sm` or `md`.
Shown in: [Parts outside a toolbar (Toolbar)](#/toolbar/parts-outside-a-toolbar) | | `of` | [`TableRef`](#type-TableRef) | — | The table, when the part does not stand inside it: the return value of `useTable`.
Shown in: [Place it outside the table (Toolbar)](#/toolbar/place-it-outside), [Parts outside a toolbar (Toolbar)](#/toolbar/parts-outside-a-toolbar), [Review cost centres against budget (Scenarios)](#/scenarios/review-cost-centres) | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | `onExport` | `(text: string) => void` | — | Gets the text instead of triggering a file.
Shown in: [Take the text](#/export/take-the-text), [Sort and export a value that is an object (Sorting)](#/sorting/own-ordering) | ##### Types on this page ###### `ToolbarSize` The size of a toolbar's controls. ```ts type ToolbarSize = "sm" | "md"; ``` ###### `TableRef` What a part without a row type needs from a table – for `of`. ```ts type TableRef = Omit, "setFilter" | "conditionOf"> & { readonly Table: unknown; /** Without a row kind: any id, any condition. */ setFilter(column: string, condition: unknown): void; }; ``` #### Known limits - One format, CSV: no workbook, PDF or print layout. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### View What the user did to the table – search, conditions, sort, hidden columns, widths, page – as one value to keep and hand back. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/view/ The table remembers nothing, not in the address and not in any storage. Everything stands in `t.view`, and `onViewChange` reports every change of it; keep it where the application keeps things. `initialView` is read once; `t.setView(view)` goes to another view later and is not reported through `onViewChange`, so two tables stay in step by `onViewChange: (v) => other.setView(v)` in both directions. #### Examples ##### Keep and restore a view `t.view` holds search, sort, hidden columns and widths as one value; keep one and go back to it with `t.setView`, no `key` needed. Sort, hide, drag a width, then restore. ```tsx import { useState } from "react"; import { Button, Stack, Text } from "@umriss-ui/core"; import { ColumnMenu, Search, Toolbar, useTable } from "@umriss-ui/table"; import type { TableView } from "@umriss-ui/table"; const START_VIEW: TableView = { sort: [{ column: "amount", direction: "desc" }], hidden: ["costCentre"], }; interface Invoice { id: string; supplier: string; costCentre: string; amount: number; } const INVOICES: Invoice[] = [ { id: "INV-26-0318", supplier: "Brandlow Office Supply", costCentre: "Facilities", amount: 1951.24 }, { id: "INV-26-0317", supplier: "Kettering & Shaw Events", costCentre: "Marketing", amount: 15110 }, { id: "INV-26-0309", supplier: "Nimbrel Software", costCentre: "IT", amount: 7288 }, { id: "INV-26-0302", supplier: "Fenwright Legal", costCentre: "Finance", amount: 3770 }, ]; export default function InitialView() { /* The table holds its view and starts from `initialView`; `t.view` is what it shows now, and `t.setView` restores a kept one. */ const [kept, setKept] = useState(null); const [reports, setReports] = useState(0); const t = useTable(INVOICES, { rowKey: (i) => i.id, initialView: START_VIEW, onViewChange: () => setReports((n) => n + 1) }); const { Table, Column, view, setView } = t; return (
What the application would keep - reported {reports} times; a restore is not a report {/* JSON has no space to break at: without this it ran past a phone's card. */} {JSON.stringify(t.view)}
); } ``` #### Keyboard The keys of [First table](#/first-table/keyboard-first-table), [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input), [ColumnMenu](#/columnmenu/keyboard-columnmenu) and [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button) apply here. #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | | [`initialView`](#/first-table/TableOptions-initialView) | [`TableView`](#/first-table/type-TableView) | — | The view to start from, read once when the hook first runs; a different value on a later render is ignored, as React ignores a changed `defaultValue`. To go to a view later, call `setView`. The table remembers none: where a view is kept is the application's decision. Names no column carries fall out. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onViewChange`](#/first-table/TableOptions-onViewChange) | `(view: `[`TableView`](#/first-table/type-TableView)`) => void` | — | Every change the reader makes, once, always the whole view - the one to keep, or to hand another table's `setView`. Not called for the view the table starts with, nor for one `setView` applies. | From `TableSnapshot` — the full table stands on [First table](#/first-table/type-TableSnapshot). | Prop | Type | Default | Description | |---|---|---|---| | [`setView`](#/first-table/TableSnapshot-setView) *required* | `(view: `[`TableView`](#/first-table/type-TableView)`) => void` | — | Goes to a whole view; what it leaves out goes back to where the table starts, and names no column carries fall out. Not reported through `onViewChange`: two tables kept in step by `onViewChange: (v) => other.setView(v)` in both directions do not answer each other. A view with the content already shown changes nothing. | | [`view`](#/first-table/TableSnapshot-view) *required* | [`TableView`](#/first-table/type-TableView) | — | The part of the state an application can keep; whatever still stands as the options set it up is absent. | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | | [`format`](#/column/FieldColumn-format) | [`FormatFor`](#/column/type-FormatFor)`` | — | A standard presentation by name: `"percent"`, `"count"` or `{ decimals }` for numbers, `"date"`, `"time"` or `"dateTime"` for points in time. Alignment, sorting, export and footer stay with the value. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | | [`resizable`](#/column/ColumnBase-resizable) | `boolean` | — | Shows the drag grip on the header cell. | From `SearchProps` — the full table stands on [Search](#/search/type-SearchProps). | Prop | Type | Default | Description | |---|---|---|---| | [`placeholder`](#/search/SearchProps-placeholder) | `string` | the wording's "Search …" | The placeholder in the field. | From `ToolbarProps` — the full table stands on [Toolbar](#/toolbar/type-ToolbarProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/toolbar/ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. | ## Many rows Tables of thousands of rows, in the browser or on a server. ### Pagination The bar under the table that splits long results into pages, with the page size to choose (also called a pager). The table pages only where one stands; without it every filtered row is shown. ```ts import { Pagination } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/pagination/ `pageSize` on the hook sets the rows per page, `pageSizes` the choices. A search, a filter, a new sort or a new page size go back to page one, and a page that no longer exists after a filter becomes the last one. A virtualised table does not page, and the bar is not drawn. #### Examples ##### Page through shipments Put a `Pagination` in the table and set `pageSize` on the hook; a search, a filter or a new sort goes back to page one. ```tsx import { Pagination, Search, Toolbar, 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; }), ); export default function PageThroughShipments() { const { Table, Column } = useTable(SHIPMENTS, { rowKey: (s) => s.id, pageSize: 10 }); return (
); } ``` ##### Show every row Without a `Pagination` the table does not page at all – here twelve rows stand despite `pageSize: 5` on the hook. ```tsx import { useTable } from "@umriss-ui/table"; interface Delivery { id: string; parcels: number; } const DELIVERIES: Delivery[] = Array.from({ length: 12 }, (_, i) => ({ id: `FP-${1004300 + i * 13}`, parcels: 1 + ((i * 5) % 7), })); export default function ShowEveryRow() { const { Table, Column } = useTable(DELIVERIES, { rowKey: (d) => d.id, pageSize: 5 }); return (
); } ``` #### When to use something else - Thousands of rows the user scrolls through without pages → Virtualisation #### Keyboard The keys of [First table](#/first-table/keyboard-first-table), [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input), [Select](https://romanhaendler.github.io/umriss-ui/core/select/#keyboard-select) and [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button) apply here. #### API ##### `PaginationProps` | Prop | Type | Default | Description | |---|---|---|---| | `pageSizes` | `readonly number[]` | `[10, 25, 50]` | The selectable page sizes.
Shown in: [Page through shipments](#/pagination/page-through-shipments), [Search and page from a header of your own (First table)](#/first-table/search-and-page-from-a-header-of-your-own), [Restrict which rows a table has (Pre-filter)](#/pre-filter/pre-filter) and 4 more | | `of` | [`TableRef`](#type-TableRef) | — | The table, when the part does not stand inside it: the return value of `useTable`.
Shown in: [Parts outside a toolbar (Toolbar)](#/toolbar/parts-outside-a-toolbar) | ###### Styling | Prop | Type | Default | Description | |---|---|---|---| | `className` | `string` | — | Classes on the pagination bar. | ##### Types on this page ###### `TableRef` What a part without a row type needs from a table – for `of`. ```ts type TableRef = Omit, "setFilter" | "conditionOf"> & { readonly Table: unknown; /** Without a row kind: any id, any condition. */ setFilter(column: string, condition: unknown): void; }; ``` #### Known limits - In [server mode](#/server-mode) the page goes to the server in the request, and `rowCount` decides how many pages there are. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### Virtualisation Thousands of rows the user scrolls through without pages: only the rows in view are rendered, and everything else still reaches every row. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/virtualisation/ #### Examples ##### Scroll twenty thousand rows `virtual` renders only the rows in view; sorting, search, “select all” and the arrow keys still reach every row. It replaces paging. ```tsx import { useMemo } from "react"; import { Badge, Stack, Text, useFormats } from "@umriss-ui/core"; import { Search, Toolbar, useTable } from "@umriss-ui/table"; /* The minimum width comes from the application's stylesheet - `width` is an initial width - so that header and row header have something to stick against sideways as well. */ const STYLE = ` .request-log table { min-width: 1300px; } `; interface Request { id: string; service: string; route: string; status: 200 | 404 | 500; duration: number; size: number; region: string; client: string; } const SERVICES = ["Checkout", "Billing", "Sign-in", "Search", "Image service", "Notifications", "Webhooks", "Reporting"] as const; const ROUTES = ["/api/cart", "/api/pay", "/api/session", "/api/search", "/img/thumb", "/api/notify", "/hooks/deliver", "/api/report"] as const; const REGIONS = ["eu-west", "eu-central", "us-east", "ap-south"] as const; const CLIENTS = ["Web shop", "iOS app", "Android app", "Partner API"] as const; const STATUSES = [200, 200, 200, 200, 200, 200, 404, 500] as const; const TONE = { 200: "success", 404: "warning", 500: "danger" } as const; const pick = (field: readonly T[], share: number): T => field[Math.floor(share * field.length) % field.length]!; /* A linear congruential generator: the same seed yields the same rows. */ function generate(count: number): Request[] { let seed = 20260317; const next = () => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648; }; return Array.from({ length: count }, (_, i) => { const service = Math.floor(next() * SERVICES.length); return { id: `REQ-${String(i + 1).padStart(5, "0")}`, service: SERVICES[service]!, route: ROUTES[service]!, status: pick(STATUSES, next()), duration: Math.round(20 + next() * 900), size: Math.round(next() * 5000) / 10, region: pick(REGIONS, next()), client: pick(CLIENTS, next()), }; }); } export default function Virtualisation() { const requests = useMemo(() => generate(20_000), []); const formats = useFormats(); const t = useTable(requests, { rowKey: (r) => r.id, defaultSort: { column: "id", direction: "asc" }, virtual: true, }); const { Table, Column } = t; return ( {(status) => {status}}
{t.visible.length} of {formats.count(t.filtered.length)} rendered · {t.selection.count} selected
); } ``` #### Keyboard | Key | Action | |---|---| | `↑ ↓ Home End` | On a row: moves to the next, previous, first or last row, rendered or not. | #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`selectable`](#/first-table/TableProps-selectable) | `boolean` | — | Adds the selection column. "All" means: the filtered set. | | [`stickyHeader`](#/first-table/TableProps-stickyHeader) | `boolean` | — | Keeps the header row visible while scrolling. | | [`stickyRowHeader`](#/first-table/TableProps-stickyRowHeader) | `boolean` | — | Keeps the row header column visible while scrolling sideways - the same as `pin="start"` on the row header. | | [`maxHeight`](#/first-table/TableProps-maxHeight) | `string` | — | Height of the scroll area, e.g. "480px". A virtualised table needs it. | | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | ###### Styling | Prop | Type | Default | Description | |---|---|---|---| | [`className`](#/first-table/TableProps-className) | `string` | — | Classes on the outer frame of the table. | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | | [`defaultSort`](#/first-table/TableOptions-defaultSort) | [`SortLevel`](#/first-table/type-SortLevel)` \| readonly `[`SortLevel`](#/first-table/type-SortLevel)`[] \| null` | `null` | The application's sort; it is the default that `view` leaves out. | | [`virtual`](#/first-table/TableOptions-virtual) | `boolean \| { overscan?: number }` | — | Renders only what stands in the scroll area; paging is then off. Every row is one row pitch tall, which the table measures itself ([ADR-0042](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0042-a-rows-height-never-follows-what-it-shows.md)). | From `TableSnapshot` — the full table stands on [First table](#/first-table/type-TableSnapshot). | Prop | Type | Default | Description | |---|---|---|---| | [`filtered`](#/first-table/TableSnapshot-filtered) *required* | `readonly Z[]` | — | The filtered set: what the pre-filter admits and search and filters leave, across all pages. | | [`visible`](#/first-table/TableSnapshot-visible) *required* | `readonly Z[]` | — | What is rendered right now: the page or the window. | | [`selection`](#/first-table/TableSnapshot-selection) *required* | [`TableSelection`](#/first-table/type-TableSelection)`` | — | The selection – its own or the one handed in through `selection`. | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | | [`format`](#/column/FieldColumn-format) | [`FormatFor`](#/column/type-FormatFor)`` | — | A standard presentation by name: `"percent"`, `"count"` or `{ decimals }` for numbers, `"date"`, `"time"` or `"dateTime"` for points in time. Alignment, sorting, export and footer stay with the value. | | [`children`](#/column/FieldColumn-children) | [`Presentation`](#/column/type-Presentation)`` | — | How the value appears: `(value, row)`. Never called for an absent value. Required where the value has no text form. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | | [`width`](#/column/ColumnBase-width) | `number` | the browser's | Initial width in pixels. | From `SearchProps` — the full table stands on [Search](#/search/type-SearchProps). | Prop | Type | Default | Description | |---|---|---|---| | [`placeholder`](#/search/SearchProps-placeholder) | `string` | the wording's "Search …" | The placeholder in the field. | From `ToolbarProps` — the full table stands on [Toolbar](#/toolbar/type-ToolbarProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/toolbar/ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. | #### Known limits - Paging and virtualisation do not combine; one table does one or the other. - No loading more rows while scrolling: a table on a server pages ([Server mode](#/server-mode)). What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ### Benchmark Up to two hundred thousand rows, measured in this browser rather than claimed: the first render, a scroll step, a sort, a keystroke, a page, a fold, a hidden column and a re-render of the caller. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/benchmark/ The figures come from the clock around each change, with nothing added to the table. The reference figures in `packages/table/docs/capabilities.md` come from here. #### Examples ##### Measure two hundred thousand rows Load up to 200 000 rows and run a change: the times say what rendering, scrolling, sorting, searching, paging, folding and hiding cost here. ```tsx /* What the table costs, measured from outside with the clock: the first render, a scroll step, a sort, a search keystroke, a page turned, a group folded, a column hidden and a re-render of the caller with nothing changed. The page adds nothing to the table. Not photographed: it measures the moment it runs. A figure is the synchronous time of `flushSync` around the change - render, commit and layout effects, not the paint. Every change but the scroll is the median of five runs; the scroll is the mean over a scripted scroll, timed to the change of the rows in the DOM because the table renders it outside `flushSync`. A production build has no React profiler, so the clock is the only instrument. The page is driven by a script as well as by hand: every control and every figure carries a `data-role`, and `status` reads idle, running or done. */ import { useRef, useState } from "react"; import { flushSync } from "react-dom"; import { Badge, Button, Stack, Text } from "@umriss-ui/core"; import { Pagination, Search, Toolbar, useTable } from "@umriss-ui/table"; interface Request { id: string; service: string; status: 200 | 404 | 500; duration: number; size: number; region: string; } const SERVICES = ["Checkout", "Billing", "Sign-in", "Search", "Image service", "Notifications", "Webhooks", "Reporting"] as const; const REGIONS = ["eu-west", "eu-central", "us-east", "ap-south"] as const; const STATUSES = [200, 200, 200, 200, 200, 200, 404, 500] as const; const TONE = { 200: "success", 404: "warning", 500: "danger" } as const; /* A linear congruential generator: the same rows on every run. */ function generate(count: number): Request[] { let seed = 20260317; const next = () => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648; }; return Array.from({ length: count }, (_, i) => ({ id: `REQ-${String(i + 1).padStart(6, "0")}`, service: SERVICES[Math.floor(next() * SERVICES.length)]!, status: STATUSES[Math.floor(next() * STATUSES.length)]!, duration: Math.round(20 + next() * 900), size: Math.round(next() * 5000) / 10, region: REGIONS[Math.floor(next() * REGIONS.length)]!, })); } /** `virtual`: a virtualised table with a footer aggregate. `grouped`: the same, grouped by service with share bars. `paged`: pages instead of a window, the only mode in which a page can be turned. */ type Mode = "virtual" | "grouped" | "paged"; const SIZES = [10_000, 50_000, 200_000] as const; const MODES: readonly Mode[] = ["virtual", "grouped", "paged"]; /* The table under measurement. `tick` is not read: a new value only makes the caller render this component again, with the accessors below written inline as the examples write them. */ function Measured({ rows, mode, onTable }: { rows: Request[]; mode: Mode; tick: number; onTable: (t: Handle) => void }) { const t = useTable(rows, { rowKey: (r) => r.id, virtual: mode !== "paged", pageSize: 50, defaultGrouping: mode === "grouped" ? "service" : undefined, }); onTable(t); const { Table, Column } = t; return ( {(status) => {status}} {/* A computed column, its function written inline: a new one on every render of the caller, which is what the re-render figure is about. */} r.size} label="Size (kB)" format={{ decimals: 1 }} width={110} aggregate="sum" /> {mode === "paged" ? : null}
); } /** The calls the measurements make on the table. */ interface Handle { toggleSort: (column: string) => void; setSearch: (search: string) => void; setPage: (page: number) => void; foldAll: () => void; unfoldAll: () => void; toggleColumn: (column: string) => void; } const FIGURES = ["first-render", "scroll", "sort", "search", "page", "fold", "hide", "rerender"] as const; type Figure = (typeof FIGURES)[number]; type Status = "idle" | "running" | "done"; const REPEATS = 5; const SCROLL_STEPS = 60; const QUERIES = ["eu", "web", "500", "pay", "us-"]; const timed = (change: () => void): number => { const start = performance.now(); flushSync(change); return performance.now() - start; }; const median = (values: number[]): number => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]!; /* Lets the browser paint "running" before a measurement blocks the thread, and settles what a change left behind. */ const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); export default function Benchmark() { const [load, setLoad] = useState<{ rows: Request[]; mode: Mode; key: number }>({ rows: [], mode: "virtual", key: 0 }); const [tick, setTick] = useState(0); const [count, setCount] = useState(String(SIZES[0])); const [status, setStatus] = useState("idle"); const [figures, setFigures] = useState>>({}); const table = useRef(null); const host = useRef(null); const generated = useRef([]); /* A figure that does not apply in this mode reads "-". */ const show = (figure: Figure, ms: number | null) => setFigures((f) => ({ ...f, [figure]: ms === null ? "-" : ms.toFixed(1) })); const run = async (body: () => Promise | void) => { flushSync(() => setStatus("running")); await settle(); try { await body(); } finally { setStatus("done"); } }; const open = (n: number, mode: Mode) => run(async () => { if (generated.current.length !== n) generated.current = generate(n); const rows = generated.current; /* The previous table goes first, so the figure is a mount and no more. */ flushSync(() => setLoad({ rows: [], mode, key: load.key + 1 })); await settle(); show("first-render", timed(() => setLoad({ rows, mode, key: load.key + 2 }))); }); /* `change` is timed, `restore` is not; both run synchronously. */ const repeat = (figure: Figure, applies: boolean, change: (i: number) => void, restore?: () => void) => run(async () => { if (!applies || table.current === null) return show(figure, null); const times: number[] = []; for (let i = 0; i < REPEATS; i++) { times.push(timed(() => change(i))); if (restore) flushSync(restore); await settle(); } show(figure, median(times)); }); /* The scroll area is the table's parent, the element that carries the table's `onScroll`. */ const scroller = (): HTMLElement | null => host.current?.querySelector("table")?.parentElement ?? null; /* Each step moves the scroll position and then sends the scroll event inside the timing: the event reaches the table's handler the same way the browser's own, which comes a frame later, does. */ const measureScroll = () => run(async () => { const el = scroller(); if (el === null || mode === "paged") return show("scroll", null); el.scrollTop = 0; await settle(); let total = 0; for (let i = 0; i < SCROLL_STEPS; i++) { const start = performance.now(); await new Promise((resolve) => { const rows = new MutationObserver(() => { rows.disconnect(); resolve(); }); rows.observe(el, { childList: true, subtree: true }); el.scrollTop += 600; el.dispatchEvent(new Event("scroll")); setTimeout(() => { rows.disconnect(); resolve(); }, 1000); }); total += performance.now() - start; /* The browser's own scroll event follows a frame later and finds the window already in place. */ await new Promise((resolve) => setTimeout(resolve, 30)); } show("scroll", total / SCROLL_STEPS); }); const t = () => table.current!; const mode = load.mode; return ( {SIZES.map((n) => ( ))} setCount(event.target.value)} /> {MODES.map((m) => ( ))}

Status {status}
Rows {load.rows.length}
{FIGURES.map((f) => (
{f} (ms) {figures[f] ?? "-"}
))}
{load.rows.length > 0 ? ( { table.current = handle; }} /> ) : null}
); } ``` #### Keyboard The keys of [First table](#/first-table/keyboard-first-table), [Virtualisation](#/virtualisation/keyboard-virtualisation) and [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button) apply here. #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`stickyHeader`](#/first-table/TableProps-stickyHeader) | `boolean` | — | Keeps the header row visible while scrolling. | | [`maxHeight`](#/first-table/TableProps-maxHeight) | `string` | — | Height of the scroll area, e.g. "480px". A virtualised table needs it. | | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | | [`pageSize`](#/first-table/TableOptions-pageSize) | `number` | `10` | How many rows a page holds. | | [`defaultGrouping`](#/first-table/TableOptions-defaultGrouping) | [`GroupingId`](#/first-table/type-GroupingId)` \| readonly `[`GroupingId`](#/first-table/type-GroupingId)`[]` | ungrouped, until the user groups | The application's grouping: a column or group key, or up to three, the outermost first. It is the default that `view` leaves out. | | [`virtual`](#/first-table/TableOptions-virtual) | `boolean \| { overscan?: number }` | — | Renders only what stands in the scroll area; paging is then off. Every row is one row pitch tall, which the table measures itself ([ADR-0042](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0042-a-rows-height-never-follows-what-it-shows.md)). | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | | [`children`](#/column/FieldColumn-children) | [`Presentation`](#/column/type-Presentation)`` | — | How the value appears: `(value, row)`. Never called for an absent value. Required where the value has no text form. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | | [`width`](#/column/ColumnBase-width) | `number` | the browser's | Initial width in pixels. | From `SearchProps` — the full table stands on [Search](#/search/type-SearchProps). | Prop | Type | Default | Description | |---|---|---|---| | [`placeholder`](#/search/SearchProps-placeholder) | `string` | the wording's "Search …" | The placeholder in the field. | From `AggregateOptions` — the full table stands on [Aggregate](#/aggregate/type-AggregateOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`aggregate`](#/aggregate/AggregateOptions-aggregate) | [`AggregateFor`](#/aggregate/type-AggregateFor)`` | — | What the column's values come to: in the footer over the filtered set, in a group's header over its rows. `"sum"`, `"avg"` for numbers; `"min"`, `"max"` for numbers and points in time; `"range"` for points in time; `"count"`, `"distinct"` for every value with a text form - or a function of one's own. Always from the values, never from other aggregates; absent values count towards nothing. | From `ToolbarProps` — the full table stands on [Toolbar](#/toolbar/type-ToolbarProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/toolbar/ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. | ### Server mode A table over rows a server holds: the application hands in one page and the server's count, and the table reports what to fetch next (also called server-side paging, sorting and filtering). ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/server-mode/ `onRequest` reports the request whenever what decides the rows changes – search, conditions, sort, page, page size – complete, defaults included, since a server has no default of the table's. Requests are the application's: the table shows whatever rows it is given, so an answer to a view the user has already left is the application's to drop. #### Examples ##### Hand the table one page `server` takes the rows of one page and the server's `rowCount`; `onRequest` reports the page the user asks for next. ```tsx import { useState } from "react"; import { Pagination, useTable } from "@umriss-ui/table"; import type { TableRequest } 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; }), ); /* The server is a slice of an array here; in an application it is a request. */ const serve = (view: TableRequest) => { const start = (view.page - 1) * view.pageSize; return { rows: SHIPMENTS.slice(start, start + view.pageSize), rowCount: SHIPMENTS.length }; }; export default function OnlyPages() { const [answer, setAnswer] = useState(() => serve({ search: "", conditions: {}, sort: [], page: 1, pageSize: 10 })); const { Table, Column } = useTable(answer.rows, { rowKey: (s) => s.id, server: true, rowCount: answer.rowCount, onRequest: (view) => setAnswer(serve(view)), }); return (
); } ``` ##### Sort and search on the server The request `onRequest` reports is complete: search, sort, page and page size, defaults included. The line below shows what the server received. ```tsx import { useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { Pagination, Search, Toolbar, useTable } from "@umriss-ui/table"; import type { TableRequest } 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; }), ); const serve = (view: TableRequest) => { const term = view.search.trim().toLowerCase(); const hits = SHIPMENTS.filter((s) => !term || s.id.toLowerCase().includes(term) || s.customer.toLowerCase().includes(term)); const sort = view.sort[0]; if (sort) { const key = sort.column as keyof Shipment; const sign = sort.direction === "asc" ? 1 : -1; hits.sort((a, b) => (a[key] < b[key] ? -sign : a[key] > b[key] ? sign : 0)); } const start = (view.page - 1) * view.pageSize; return { view, rows: hits.slice(start, start + view.pageSize), rowCount: hits.length }; }; const FIRST: TableRequest = { search: "", conditions: {}, sort: [{ column: "id", direction: "asc" }], page: 1, pageSize: 10 }; export default function SortAndSearch() { const [answer, setAnswer] = useState(() => serve(FIRST)); const { Table, Column } = useTable(answer.rows, { rowKey: (s) => s.id, defaultSort: FIRST.sort[0], server: true, rowCount: answer.rowCount, onRequest: (view) => setAnswer(serve(view)), }); const { search, sort, page, pageSize } = answer.view; return (
Received: page {page} · {pageSize} rows · {sort.map((s) => `${s.column} ${s.direction}`).join(", ")} {search && ` · “${search}”`}
); } ``` ##### Name the values a list filter offers The table holds one page and cannot count the values of the rest; `filterOptions` names them, and the chosen ones arrive in `view.conditions`. ```tsx import { useState } from "react"; import { Pagination, useTable } from "@umriss-ui/table"; import type { TableRequest } 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; }), ); const STATUSES = ["delivered", "out for delivery", "failed attempt"] as const; const serve = (view: TableRequest) => { const chosen = view.conditions.status as readonly string[] | undefined; const hits = SHIPMENTS.filter((s) => !chosen || chosen.includes(s.status)); const start = (view.page - 1) * view.pageSize; return { rows: hits.slice(start, start + view.pageSize), rowCount: hits.length }; }; export default function ListFilter() { const [answer, setAnswer] = useState(() => serve({ search: "", conditions: {}, sort: [], page: 1, pageSize: 10 })); const { Table, Column } = useTable(answer.rows, { rowKey: (s) => s.id, server: true, rowCount: answer.rowCount, onRequest: (view) => setAnswer(serve(view)), filterOptions: (column) => (column === "status" ? STATUSES : []), }); return (
); } ``` ##### Show loading, and drop a late answer `loading` marks the table while a request runs: the page it has stays, dimmed after a moment, and nothing below it moves. An answer to a view the user has already left is the application's to drop; the table shows whatever rows it is given. ```tsx import { useRef, useState } from "react"; import { Pagination, useTable } from "@umriss-ui/table"; import type { TableRequest } 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; }), ); /* Every answer takes 600 ms. Page on quickly: only the last request lands. */ const LATENCY = 600; const serve = (view: TableRequest) => { const start = (view.page - 1) * view.pageSize; return { rows: SHIPMENTS.slice(start, start + view.pageSize), rowCount: SHIPMENTS.length }; }; export default function LoadingAndLateAnswers() { const [answer, setAnswer] = useState(() => serve({ search: "", conditions: {}, sort: [], page: 1, pageSize: 10 })); const [loading, setLoading] = useState(false); const latest = useRef(null); const { Table, Column } = useTable(answer.rows, { rowKey: (s) => s.id, server: true, rowCount: answer.rowCount, onRequest: (view) => { latest.current = view; setLoading(true); setTimeout(() => { if (latest.current !== view) return; setAnswer(serve(view)); setLoading(false); }, LATENCY); }, }); return (
); } ``` ##### Load pages from a server `server` hands the table one page and the server's `rowCount`; `onRequest` reports search, conditions, sort and page to fetch the next. ```tsx import { useRef, useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { ColumnMenu, Export, Pagination, Search, Toolbar, useTable } from "@umriss-ui/table"; import type { TableRequest } from "@umriss-ui/table"; /* The server below is a fake: a million requests computed from their index, every answer delayed by 400 ms. Dropping a late answer to an older view is the application's part, and it stands here in full. */ interface Request { id: string; service: string; duration: number; } const COUNT = 1_000_000; const LATENCY = 400; const SERVICES = ["Checkout", "Billing", "Sign-in", "Search", "Image service", "Notifications", "Webhooks", "Reporting"] as const; const BY_ID = { column: "id", direction: "asc" } as const; /* A row from its index alone - the same every run, and no array of a million. */ const mix = (i: number, salt: number) => { let h = Math.imul(i ^ salt, 2654435761) >>> 0; h = Math.imul(h ^ (h >>> 15), 2246822519) >>> 0; return (h ^ (h >>> 13)) >>> 0; }; const request = (i: number): Request => ({ id: `REQ-${String(i + 1).padStart(7, "0")}`, service: SERVICES[mix(i, 7) % SERVICES.length]!, duration: 20 + (mix(i, 11) % 900), }); /* Filter, sort, then the page. The order of the last filter and sort is kept, the way a database keeps an index warm. */ let warm: { key: string; order: Int32Array | null } = { key: "", order: null }; function serve(view: TableRequest): { rows: Request[]; rowCount: number } { const search = view.search.trim().toLowerCase(); const services = view.conditions.service as readonly string[] | undefined; const sort = view.sort[0] ?? BY_ID; const key = JSON.stringify([search, services, sort]); if (warm.key !== key) { let order: Int32Array | null = null; if (search || services || sort.column !== "id") { const hits: number[] = []; for (let i = 0; i < COUNT; i++) { const r = request(i); if (services && !services.includes(r.service)) continue; if (search && !r.id.toLowerCase().includes(search) && !r.service.toLowerCase().includes(search)) continue; hits.push(i); } order = Int32Array.from(hits); if (sort.column === "duration") { const values = new Float64Array(COUNT); for (const i of order) values[i] = request(i).duration; order.sort((a, b) => values[a]! - values[b]! || a - b); } } warm = { key, order }; } const { order } = warm; const rowCount = order ? order.length : COUNT; const descending = sort.direction === "desc"; const start = (view.page - 1) * view.pageSize; const rows: Request[] = []; for (let n = start; n < Math.min(start + view.pageSize, rowCount); n++) { const at = descending ? rowCount - 1 - n : n; rows.push(request(order ? order[at]! : at)); } return { rows, rowCount }; } const FIRST: TableRequest = { search: "", conditions: {}, sort: [BY_ID], page: 1, pageSize: 10 }; const same = (a: TableRequest, b: TableRequest) => JSON.stringify([a.search, a.conditions, a.sort, a.page, a.pageSize]) === JSON.stringify([b.search, b.conditions, b.sort, b.page, b.pageSize]); export default function AMillionRowsOnAServer() { /* The first page comes with the document, as from a server that renders it. */ const [answer, setAnswer] = useState(() => ({ view: FIRST, ...serve(FIRST) })); const [loading, setLoading] = useState(false); const latest = useRef(FIRST); const t = useTable(answer.rows, { rowKey: (r) => r.id, defaultSort: BY_ID, server: true, rowCount: answer.rowCount, onRequest: (view) => { latest.current = view; /* Back to the view on screen before its successor was answered: the pending answer is dropped, so nothing is loading any more. */ if (same(view, answer.view)) return setLoading(false); setLoading(true); setTimeout(() => { if (latest.current !== view) return; setAnswer({ view, ...serve(view) }); setLoading(false); }, LATENCY); }, filterOptions: (column) => (column === "service" ? SERVICES : []), }); const { Table, Column } = t; const { search, sort, page, pageSize } = answer.view; return (
Answered: page {page} · {pageSize} rows · {sort.map((s) => `${s.column} ${s.direction}`).join(", ")} {search && ` · “${search}”`} · {t.selection.count} selected
); } ``` #### When to use something else - Thousands of rows that are all in the browser → Virtualisation #### Keyboard The keys of [First table](#/first-table/keyboard-first-table), [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input), [ColumnMenu](#/columnmenu/keyboard-columnmenu), [Select](https://romanhaendler.github.io/umriss-ui/core/select/#keyboard-select), [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button) and [Checkbox](https://romanhaendler.github.io/umriss-ui/core/checkbox/#keyboard-checkbox) apply here. #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`selectable`](#/first-table/TableProps-selectable) | `boolean` | — | Adds the selection column. "All" means: the filtered set. | | [`loading`](#/first-table/TableProps-loading) | `boolean` | — | Shows placeholder rows instead of the rows. | | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | | [`defaultSort`](#/first-table/TableOptions-defaultSort) | [`SortLevel`](#/first-table/type-SortLevel)` \| readonly `[`SortLevel`](#/first-table/type-SortLevel)`[] \| null` | `null` | The application's sort; it is the default that `view` leaves out. | From `TableSnapshot` — the full table stands on [First table](#/first-table/type-TableSnapshot). | Prop | Type | Default | Description | |---|---|---|---| | [`selection`](#/first-table/TableSnapshot-selection) *required* | [`TableSelection`](#/first-table/type-TableSelection)`` | — | The selection – its own or the one handed in through `selection`. | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | | [`filter`](#/column/FieldColumn-filter) | [`FilterFor`](#/column/type-FilterFor)`` | — | A filter in the column header: `"list"` offers the values that occur, `"range"` two bounds (numbers or points in time), a filter from `columnFilter` asks whatever it asks itself. Its condition stands in the table toolbar. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | | [`sortable`](#/column/ColumnBase-sortable) | `boolean` | `true` when the value is text, a number, a point in time or a boolean | Whether the column can be sorted by. | From `SearchProps` — the full table stands on [Search](#/search/type-SearchProps). | Prop | Type | Default | Description | |---|---|---|---| | [`placeholder`](#/search/SearchProps-placeholder) | `string` | the wording's "Search …" | The placeholder in the field. | From `ToolbarProps` — the full table stands on [Toolbar](#/toolbar/type-ToolbarProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/toolbar/ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. | #### Known limits - The table sorts, filters and pages nothing itself, and it shows no groups and no footer: they would be made of one page. - “Select all” and the export act on the page the table holds, and the export button says so. - A list filter offers what `filterOptions` names; the table cannot count values it does not hold. - No loading more rows while scrolling: paging and virtualisation do not combine. What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ## Editing Working in the cells instead of reading them. ### Grid mode Make the table one Tab stop whose cells the arrow keys walk, as in a spreadsheet. It is the mode for editing; keep it off for tables that are only read. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/grid-mode/ A table is read by default: a native table, where every control is one Tab away and a screen reader keeps its table keys. Grid mode changes that on request only ([ADR-0034](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0034-the-table-becomes-a-grid-only-on-request.md)). #### Examples ##### Work cell by cell `grid` makes the table one Tab stop whose cells the arrows walk; Enter reaches a cell's controls. Keep it off for tables that are only read. ```tsx import { useTable } from "@umriss-ui/table"; interface Service { id: string; name: string; team: string; requests: number; state: string; } const SERVICES: Service[] = [ { id: "checkout", name: "Checkout", team: "Payments", requests: 1840, state: "Degraded" }, { id: "billing", name: "Billing", team: "Payments", requests: 620, state: "Healthy" }, { id: "sign-in", name: "Sign-in", team: "Identity", requests: 2210, state: "Healthy" }, { id: "search", name: "Search", team: "Discovery", requests: 3105, state: "Healthy" }, { id: "webhooks", name: "Webhooks", team: "Integrations", requests: 410, state: "Delayed" }, ]; export default function GridMode() { const { Table, Column, RowActions, Action } = useTable(SERVICES, { rowKey: (s) => s.id }); return ( undefined}>Dashboard
); } ``` ##### Combine grid mode with groups, pinning and many rows Grid mode keeps everything else: a group header is a line the arrows walk, and in a virtual window the keys reach unrendered rows. ```tsx import { useMemo, useState } from "react"; import { useTable } from "@umriss-ui/table"; interface Parcel { id: string; parcel: string; depot: string; weight: number; declared: number; note: string; } const DEPOTS = ["North depot", "Riverside depot", "East Gate depot"] as const; /* A linear congruential generator: the same seed yields the same rows. */ function generate(count: number): Parcel[] { let seed = 20260924; const next = () => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648; }; return Array.from({ length: count }, (_, i) => ({ id: `x${i}`, parcel: `FP-${String(1_004_210 + i)}`, depot: DEPOTS[Math.floor(next() * DEPOTS.length)]!, weight: Math.round(next() * 300) / 10, declared: Math.round(next() * 50_000) / 100, note: "", })); } export default function TheWholeGrid() { const generated = useMemo(() => generate(2000), []); const [parcels, setParcels] = useState(generated); const { Table, Column } = useTable(parcels, { rowKey: (p) => p.id, defaultGrouping: "depot", defaultSort: { column: "parcel", direction: "asc" }, virtual: true, }); return ( setParcels((all) => all.map((p) => (p.id === rowKey ? { ...p, [columnId]: value } : p))) } > (value === null || value <= 0 || value > 31.5 ? "Between 0 and 31.5 kg" : undefined)} width={140} />
); } ``` #### Keyboard | Key | Action | |---|---| | `Tab` | Enters and leaves the grid, which is one stop. | | `← → ↑ ↓` | Moves the active cell. | | `Home / End` | The first or last cell of the row. | | `Ctrl + Home / Ctrl + End` | The first cell of the head, the last cell of the footer. | | `Page Up / Page Down` | Moves by the rows in view. | | `Click` | Opens the editor of a cell that edits; a select shows its list, a day its calendar. | | `Enter / F2` | Starts an edit, or reaches the cell's own controls. | | `Escape` | Cancels an edit, or goes back from a control to its cell. | | `Ctrl / Cmd + C` | Copies the selected rows, or the active cell; its cell flashes. In an open editor with nothing marked it copies the field's value. | | `Space` | Selects the row of the active cell. | #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`selectable`](#/first-table/TableProps-selectable) | `boolean` | — | Adds the selection column. "All" means: the filtered set. | | [`stickyHeader`](#/first-table/TableProps-stickyHeader) | `boolean` | — | Keeps the header row visible while scrolling. | | [`maxHeight`](#/first-table/TableProps-maxHeight) | `string` | — | Height of the scroll area, e.g. "480px". A virtualised table needs it. | | [`grid`](#/first-table/TableProps-grid) | `boolean` | — | Grid mode: the table is one tab stop, and the arrows walk its cells - Home and End in the row, Ctrl+Home and Ctrl+End to the table's ends, PageUp and PageDown by the rows in view. A cell's own controls are reached with Enter or F2 and left with Escape; a column with `edit` is edited in place. Without it the table stays a native table. | | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onCellEdit`](#/first-table/TableProps-onCellEdit) | `(edit: `[`CellEdit`](#/first-table/type-CellEdit)`) => void` | — | Grid mode: an edit the user committed. The table applies nothing; the cell shows the new value when the rows passed in carry it. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | | [`defaultSort`](#/first-table/TableOptions-defaultSort) | [`SortLevel`](#/first-table/type-SortLevel)` \| readonly `[`SortLevel`](#/first-table/type-SortLevel)`[] \| null` | `null` | The application's sort; it is the default that `view` leaves out. | | [`defaultGrouping`](#/first-table/TableOptions-defaultGrouping) | [`GroupingId`](#/first-table/type-GroupingId)` \| readonly `[`GroupingId`](#/first-table/type-GroupingId)`[]` | ungrouped, until the user groups | The application's grouping: a column or group key, or up to three, the outermost first. It is the default that `view` leaves out. | | [`virtual`](#/first-table/TableOptions-virtual) | `boolean \| { overscan?: number }` | — | Renders only what stands in the scroll area; paging is then off. Every row is one row pitch tall, which the table measures itself ([ADR-0042](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0042-a-rows-height-never-follows-what-it-shows.md)). | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | | [`format`](#/column/FieldColumn-format) | [`FormatFor`](#/column/type-FormatFor)`` | — | A standard presentation by name: `"percent"`, `"count"` or `{ decimals }` for numbers, `"date"`, `"time"` or `"dateTime"` for points in time. Alignment, sorting, export and footer stay with the value. | | [`edit`](#/column/FieldColumn-edit) | [`EditFor`](#/column/type-EditFor)`` | — | Edits the column's cells in place in grid mode: Enter, F2 or typing starts it, Enter or Tab commits, Escape cancels. The table applies nothing - the edit is reported through the table's `onCellEdit`. From `EditOptions`. | | [`validate`](#/column/FieldColumn-validate) | `(value: Z[K] \| null, row: Z) => string \| null \| undefined` | — | Checks a draft before it is reported: a message keeps the editor open and stands beneath the field, as in a `FormField`; nothing lets it through. From `EditOptions`. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | | [`width`](#/column/ColumnBase-width) | `number` | the browser's | Initial width in pixels. | | [`pin`](#/column/ColumnBase-pin) | [`Pin`](#/column/type-Pin)
`"start" \| "end"` | — | Keeps the column in view while the table scrolls sideways: in a block before every other column, or after them. The user can change it in the column menu; the view carries the change. Pinned blocks take at most half of the width the table shows: on a narrower one the end block, then the start block scrolls with the rest, and sticks again when there is room. | From `AggregateOptions` — the full table stands on [Aggregate](#/aggregate/type-AggregateOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`aggregate`](#/aggregate/AggregateOptions-aggregate) | [`AggregateFor`](#/aggregate/type-AggregateFor)`` | — | What the column's values come to: in the footer over the filtered set, in a group's header over its rows. `"sum"`, `"avg"` for numbers; `"min"`, `"max"` for numbers and points in time; `"range"` for points in time; `"count"`, `"distinct"` for every value with a text form - or a function of one's own. Always from the values, never from other aggregates; absent values count towards nothing. | From `RowActionsProps` — the full table stands on [RowActions](#/rowactions/type-RowActionsProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/rowactions/RowActionsProps-children) *required* | `ReactNode` | — | The row's `Action`s. | From `ActionProps` — the full table stands on [RowActions](#/rowactions/type-ActionProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/rowactions/ActionProps-children) *required* | `string` | — | The label – text, because it is read out together with the row's name. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onSelect`](#/rowactions/ActionProps-onSelect) *required* | `((row: Z) => void) \| ((rows: readonly Z[]) => void)` | — | Gets the row the action was triggered on. With `bulk` a list: at the row one made from it, in the table toolbar the selection. | ### Edits Let people change a value in its cell. The table reports each edit, and the application writes it into its rows. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/edits/ An edit is reported, never applied: the application owns the rows. `validate` keeps the editor open with its message until the draft passes. By default each cell is reported as it is left (`onCellEdit`) - for another cell, or by a click outside the table. `editMode="row"` opens a whole row as one draft and reports it only when it is saved (`onRowSave`); a row with a draft is left only by saving or discarding it ([ADR-0036](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0036-a-grid-edits-on-the-first-click-and-saves-rows-on-purpose.md)). `onRowAdd` puts up "New row" - in the toolbar, or beneath the table without one - and `onRowDelete` a Delete that asks inside its row. Both work in either mode. #### Examples ##### Edit a comments column `edit="text"` lets a column edit; the table reports each edit through `onCellEdit` and the application writes it into its rows. ```tsx import { useState } from "react"; import { useTable } from "@umriss-ui/table"; interface Tour { id: string; driver: string; stops: number; comment: string; } const TOURS: Tour[] = [ { id: "T-01", driver: "Martin Hale", stops: 11, comment: "" }, { id: "T-02", driver: "Nadia Petrova", stops: 9, comment: "Van swapped at 10:40" }, { id: "T-03", driver: "Owen Carter", stops: 5, comment: "" }, ]; export default function CommentsColumn() { const [tours, setTours] = useState(TOURS); const { Table, Column } = useTable(tours, { rowKey: (t) => t.id }); return ( setTours((all) => all.map((t) => (t.id === rowKey ? { ...t, comment: String(value) } : t)))} >
); } ``` ##### Check an edit before it is reported Each editing column gets the core field for its value; `validate` keeps the editor open with its message until the draft passes. ```tsx import { useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; import type { CellEdit } from "@umriss-ui/table"; type Role = "Developer" | "Designer" | "Product manager" | "QA engineer"; interface Person { id: string; name: string; team: string; capacity: number; role: Role; availableFrom: Date; } const PEOPLE: Person[] = [ { id: "maya", name: "Maya Lindgren", team: "Web", capacity: 32, role: "Product manager", availableFrom: new Date(2026, 2, 2) }, { id: "arjun", name: "Arjun Mehta", team: "Web", capacity: 40, role: "Developer", availableFrom: new Date(2026, 2, 23) }, { id: "noah", name: "Noah Fischer", team: "Web", capacity: 24, role: "Designer", availableFrom: new Date(2026, 2, 16) }, { id: "david", name: "David Kowalski", team: "Apps", capacity: 20, role: "QA engineer", availableFrom: new Date(2026, 2, 16) }, ]; export default function ValidatedEdits() { const [people, setPeople] = useState(PEOPLE); const [last, setLast] = useState | null>(null); const { Table, Column } = useTable(people, { rowKey: (p) => p.id }); const apply = (edit: CellEdit) => { setPeople((all) => all.map((p) => (p.id === edit.rowKey ? { ...p, [edit.columnId]: edit.value } : p))); setLast(edit); }; return ( value === null ? "A person needs a capacity" : value < 4 || value > 40 ? "Between 4 and 40 hours a week" : undefined } />
{last ? `Last edit: ${last.row.name}, ${last.columnId}` : "No edit yet."}
); } ``` ##### Save a row on purpose `editMode="row"` opens every cell of a row that edits at once. Nothing is reported until Save or Enter; `onRowSave` hands over only the columns that changed. ```tsx import { useState } from "react"; import { Stack, Text } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; import type { RowSave } from "@umriss-ui/table"; interface Project { id: string; name: string; phase: string; owner: string; days: number; } const PROJECTS: Project[] = [ { id: "P-1", name: "Checkout redesign", phase: "Build", owner: "Ines Duarte", days: 40 }, { id: "P-2", name: "Mobile app", phase: "Discovery", owner: "Tomasz Wrona", days: 15 }, { id: "P-3", name: "Data warehouse", phase: "Launch", owner: "Amira Haddad", days: 60 }, ]; export default function SaveARowOnPurpose() { const [projects, setProjects] = useState(PROJECTS); const [last, setLast] = useState | null>(null); const { Table, Column } = useTable(projects, { rowKey: (p) => p.id }); const save = (saved: RowSave) => { setProjects((all) => all.map((p) => (p.id === saved.rowKey ? { ...p, ...saved.changes } : p))); setLast(saved); }; return (
{last ? `Saved ${last.row.name}: ${Object.keys(last.changes).join(", ")}` : "Nothing saved yet."}
); } ``` ##### Check a whole row before it is saved On Save every open cell runs its `validate`; each message stands beneath its field, and the row stays open until all of them pass. A click into another row is refused while the draft is open. ```tsx import { useState } from "react"; import { useTable } from "@umriss-ui/table"; interface Offer { id: string; product: string; unit: string; price: number | null; minimum: number | null; maximum: number | null; } const OFFERS: Offer[] = [ { id: "o1", product: "Printer paper A4", unit: "box", price: 24.5, minimum: 5, maximum: 200 }, { id: "o2", product: "Toner cartridge", unit: "piece", price: 68, minimum: 1, maximum: 40 }, { id: "o3", product: "Desk lamp", unit: "piece", price: 39.9, minimum: 2, maximum: 25 }, ]; const required = (label: string) => (value: number | null) => (value === null ? `${label} is required` : undefined); export default function CheckAWholeRow() { const [offers, setOffers] = useState(OFFERS); const { Table, Column } = useTable(offers, { rowKey: (o) => o.id }); return ( setOffers((all) => all.map((o) => (o.id === rowKey ? { ...o, ...changes } : o)))} > (value === null ? "A price is required" : value < 0 ? "Not below 0" : undefined)} />
); } ``` ##### Keep a list: add, change and delete rows `onRowAdd` puts up "New row" in the toolbar - an empty row draft above the rows, whatever the sort; `newRow` gives its first values. `onRowDelete` adds a Delete that asks inside its row. The application owns the rows: it inserts, changes and removes them itself. ```tsx import { useState } from "react"; import { Search, Toolbar, useTable } from "@umriss-ui/table"; type Kind = "Spare part" | "Consumable" | "Tool"; interface Item { id: string; name: string; kind: Kind; stock: number | null; location: string; } const ITEMS: Item[] = [ { id: "i1", name: "Burner nozzle 4 mm", kind: "Spare part", stock: 6, location: "Store A-3" }, { id: "i2", name: "Refractory mortar", kind: "Consumable", stock: 14, location: "Yard" }, { id: "i3", name: "Thermocouple type K", kind: "Spare part", stock: 3, location: "Store A-1" }, { id: "i4", name: "Torque wrench 40-200 Nm", kind: "Tool", stock: 2, location: "Workshop" }, ]; let next = ITEMS.length; export default function KeepAList() { const [items, setItems] = useState(ITEMS); const { Table, Column } = useTable(items, { rowKey: (i) => i.id }); return ( ({ kind: "Spare part", stock: 0 })} onRowSave={({ rowKey, changes }) => setItems((all) => all.map((i) => (i.id === rowKey ? { ...i, ...changes } : i)))} onRowAdd={({ values }) => setItems((all) => [...all, { id: `i${++next}`, ...(values as Omit) }])} onRowDelete={({ rowKey }) => setItems((all) => all.filter((i) => i.id !== rowKey))} > (value ? undefined : "An item needs a name")} /> (value === null || value < 0 ? "0 or more" : undefined)} />
); } ``` ##### Edit a dense table `density="compact"` holds for a row draft too: its fields and buttons are a step smaller, and a row with actions stands as tall as one without. ```tsx import { useState } from "react"; import { useTable } from "@umriss-ui/table"; interface Room { id: string; name: string; seats: number; floor: number; } const ROOMS: Room[] = [ { id: "r1", name: "Harbour", seats: 8, floor: 2 }, { id: "r2", name: "Orchard", seats: 12, floor: 3 }, { id: "r3", name: "Lantern", seats: 4, floor: 3 }, ]; export default function EditADenseTable() { const [rooms, setRooms] = useState(ROOMS); const { Table, Column } = useTable(rooms, { rowKey: (r) => r.id }); return ( setRooms((all) => all.map((r) => (r.id === rowKey ? { ...r, ...changes } : r)))} onRowDelete={({ rowKey }) => setRooms((all) => all.filter((r) => r.id !== rowKey))} >
); } ``` #### Keyboard | Key | Action | |---|---| | `Tab (while editing)` | Commits the edit and moves to the next cell that edits; in a row draft, walks its fields and buttons. | | `Enter (row draft)` | Saves the row. | | `Escape (row draft)` | Discards the row's changes. | #### Props on this page From `TableProps` — the full table stands on [First table](#/first-table/type-TableProps). | Prop | Type | Default | Description | |---|---|---|---| | [`density`](#/first-table/TableProps-density) | `"regular" \| "compact"` | the density of the `UmrissProvider`, else `"regular"` | How tightly the rows stand. | | [`grid`](#/first-table/TableProps-grid) | `boolean` | — | Grid mode: the table is one tab stop, and the arrows walk its cells - Home and End in the row, Ctrl+Home and Ctrl+End to the table's ends, PageUp and PageDown by the rows in view. A cell's own controls are reached with Enter or F2 and left with Escape; a column with `edit` is edited in place. Without it the table stays a native table. | | [`editMode`](#/first-table/TableProps-editMode) | `"cell" \| "row"` | — | Grid mode ([ADR-0036](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0036-a-grid-edits-on-the-first-click-and-saves-rows-on-purpose.md)): `"cell"` reports every cell as it is left (`onCellEdit`); `"row"` opens every cell of a row that edits at once, as one **Row draft**, and reports it only when the row is saved on purpose (`onRowSave`) - by its Save button or Enter; Escape or Discard drops it. A row with a draft is left only by saving or discarding it. | | [`newRow`](#/first-table/TableProps-newRow) | `() => Partial` | — | What a new row starts with - the values the columns read before anything is typed. Without it, nothing. | | [`children`](#/first-table/TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | [`onCellEdit`](#/first-table/TableProps-onCellEdit) | `(edit: `[`CellEdit`](#/first-table/type-CellEdit)`) => void` | — | Grid mode: an edit the user committed. The table applies nothing; the cell shows the new value when the rows passed in carry it. | | [`onRowSave`](#/first-table/TableProps-onRowSave) | `(save: `[`RowSave`](#/first-table/type-RowSave)`) => void` | — | `editMode="row"`: a saved Row draft, with the columns it changed. | | [`onRowAdd`](#/first-table/TableProps-onRowAdd) | `(add: `[`RowAdd`](#/first-table/type-RowAdd)`) => void` | — | Grid mode: puts up a "New row" button - in the table toolbar, or beneath the table without one. It opens an empty Row draft above the rows; saved, it is reported whole. The row appears when the rows passed in carry it. | | [`onRowDelete`](#/first-table/TableProps-onRowDelete) | `(remove: `[`RowDelete`](#/first-table/type-RowDelete)`) => void` | — | Grid mode: a Delete button on every row, which asks inside its row before the delete is reported. The table removes nothing. | ###### Accessibility | Prop | Type | Default | Description | |---|---|---|---| | [`ariaLabel`](#/first-table/TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). | From `TableOptions` — the full table stands on [First table](#/first-table/type-TableOptions). | Prop | Type | Default | Description | |---|---|---|---| | [`rowKey`](#/first-table/TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. | From `FieldColumn` — the full table stands on [Column](#/column/type-FieldColumn). | Prop | Type | Default | Description | |---|---|---|---| | [`value`](#/column/FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. | | [`format`](#/column/FieldColumn-format) | [`FormatFor`](#/column/type-FormatFor)`` | — | A standard presentation by name: `"percent"`, `"count"` or `{ decimals }` for numbers, `"date"`, `"time"` or `"dateTime"` for points in time. Alignment, sorting, export and footer stay with the value. | | [`edit`](#/column/FieldColumn-edit) | [`EditFor`](#/column/type-EditFor)`` | — | Edits the column's cells in place in grid mode: Enter, F2 or typing starts it, Enter or Tab commits, Escape cancels. The table applies nothing - the edit is reported through the table's `onCellEdit`. From `EditOptions`. | | [`editOptions`](#/column/FieldColumn-editOptions) | `readonly `[`Present`](#/column/type-Present)`[]` | the values that occur | What `edit="select"` offers. From `EditOptions`. | | [`validate`](#/column/FieldColumn-validate) | `(value: Z[K] \| null, row: Z) => string \| null \| undefined` | — | Checks a draft before it is reported: a message keeps the editor open and stands beneath the field, as in a `FormField`; nothing lets it through. From `EditOptions`. | From `ColumnBase` — the full table stands on [Column](#/column/type-ColumnBase). | Prop | Type | Default | Description | |---|---|---|---| | [`label`](#/column/ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. | | [`rowHeader`](#/column/ColumnBase-rowHeader) | `boolean` | — | This column names the row: row header cells, never hideable, the column `stickyRowHeader` pins, and the name by which the row's selection, expander and actions are read out. At most one per table. | | [`width`](#/column/ColumnBase-width) | `number` | the browser's | Initial width in pixels. | From `ToolbarProps` — the full table stands on [Toolbar](#/toolbar/type-ToolbarProps). | Prop | Type | Default | Description | |---|---|---|---| | [`children`](#/toolbar/ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. | #### Known limits - No cell range selection and no undo ([ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/)). What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ## Limits and alarms A reading, read against its limits. ### VerdictColumn A column that judges each value against limits – ok, warning, alarm or unknown – and shows by how much it is out (also called a threshold or status column). Use it wherever a number has a line it must not cross. ```ts import { useTable } from "@umriss-ui/table"; ``` Demo page: https://romanhaendler.github.io/umriss-ui/table/verdictcolumn/ A missing value reads “unknown”: a fourth verdict, not a blank cell. Each verdict carries a shape and a word as well as a colour, and a value past a limit shows its excess beside it. It sorts by the verdict's weight – ok, unknown, warning, alarm – so values far out on either side meet at one end; `sortBy="value"` sorts by the number. It exports the value, not the verdict. The limit set is the one @umriss-ui/core and @umriss-ui/charts read ([ADR-0006](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0006-the-limit-model-lives-in-both-packages.md)). #### Examples ##### Read latency against its objective Give a `VerdictColumn` the value and a `limits` set; each cell shows ok, warning, alarm or unknown, and how far past the limit. ```tsx import type { LimitSet } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; interface Endpoint { path: string; p95: number | null; } /* Checkout's latency objective: the 95th percentile stays below 300 ms. */ const OBJECTIVE: LimitSet = { limits: [ { value: 250, side: "upper", severity: "warning" }, { value: 300, side: "upper", severity: "alarm" }, ], }; const ENDPOINTS: Endpoint[] = [ { path: "/cart", p95: 182 }, { path: "/payment", p95: 271 }, { path: "/payment/confirm", p95: 346 }, { path: "/receipt", p95: null }, ]; export default function ReadLatency() { const { Table, Column, VerdictColumn } = useTable(ENDPOINTS, { rowKey: (e) => e.path }); return (
); } ``` ##### Sort by verdict or by value A verdict column sorts by the verdict's weight, so both overloaded and idle vans stand on top; `sortBy="value"` sorts by the number. ```tsx import { Stack, Text } from "@umriss-ui/core"; import type { LimitSet } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; interface Tour { tour: string; load: number | null; } /* The share of the vehicle's capacity a tour loads, in per cent. */ const LOAD: LimitSet = { limits: [ { value: 90, side: "upper", severity: "warning" }, { value: 100, side: "upper", severity: "alarm" }, { value: 40, side: "lower", severity: "warning" }, { value: 25, side: "lower", severity: "alarm" }, ], }; const TOURS: Tour[] = [ { tour: "T-01", load: 72 }, { tour: "T-02", load: 104 }, { tour: "T-03", load: 18 }, { tour: "T-04", load: 93 }, { tour: "T-05", load: null }, ]; function Tours({ sortBy }: { sortBy: "verdict" | "value" }) { const { Table, Column, VerdictColumn } = useTable(TOURS, { rowKey: (t) => t.tour, defaultSort: { column: "load", direction: "desc" }, }); return (
); } export default function SortByVerdictOrByValue() { return ( By verdict (the default) By value ); } ``` ##### Judge a computed share Compute the value in `value` and give the column an `id`; `format` writes value and excess alike, here as a percentage. ```tsx import type { LimitSet } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; interface CostCentre { name: string; budget: number; actual: number; } /* Spent against the month's budget. */ const SPEND: LimitSet = { limits: [ { value: 1, side: "upper", severity: "warning" }, { value: 1.1, side: "upper", severity: "alarm" }, ], }; const CENTRES: CostCentre[] = [ { name: "Sales", budget: 142_000, actual: 138_400 }, { name: "Marketing", budget: 68_000, actual: 79_300 }, { name: "IT", budget: 83_000, actual: 84_100 }, ]; export default function JudgeAComputedShare() { const { Table, Column, VerdictColumn } = useTable(CENTRES, { rowKey: (c) => c.name }); return ( c.actual / c.budget} limits={SPEND} format="percent" />
); } ``` ##### Show the worst in the footer Set `aggregate="worst"` and the footer names the worst verdict among the rows – whether anyone in the team is overbooked. ```tsx import type { LimitSet } from "@umriss-ui/core"; import { useTable } from "@umriss-ui/table"; /* Data from the planning world, written out here so the example runs on its own. */ interface Person { id: string; name: string; role: "Developer" | "Designer" | "Product manager" | "QA engineer"; team: "Web" | "Apps"; /** Hours a week they can be planned for. */ capacity: number; } const PEOPLE: readonly Person[] = [ { id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 }, { id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 }, { id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 }, { id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 }, { id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 }, { id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 }, { id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 }, { id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 }, { id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 }, { id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 }, ]; /* Hours booked this week, per person. */ const BOOKED: Record = { maya: 30, arjun: 44, chloe: 38, noah: 26, eva: 36, luis: 40, hana: 39, kofi: 35, freya: 22, david: 20, }; /* Booked hours against the person's capacity. */ const BOOKING: LimitSet = { limits: [ { value: 1, side: "upper", severity: "warning" }, { value: 1.1, side: "upper", severity: "alarm" }, ], }; export default function ShowTheWorst() { const { Table, Column, VerdictColumn } = useTable(PEOPLE, { rowKey: (p) => p.id }); return ( (BOOKED[p.id] ?? 0) / p.capacity} limits={BOOKING} format="percent" aggregate="worst" />
); } ``` #### When to use something else - A single reading in a tile or a bar → `Meter` from @umriss-ui/core #### Keyboard The keys of [First table](#/first-table/keyboard-first-table) apply here. #### API ##### `VerdictBase` | Prop | Type | Default | Description | |---|---|---|---| | `label` *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export.
Shown in: [Read latency against its objective](#/verdictcolumn/read-latency-against-its-objective), [Sort by verdict or by value](#/verdictcolumn/sort-by-verdict-or-by-value), [Judge a computed share](#/verdictcolumn/judge-a-computed-share) and 5 more | | `limits` *required* | [`LimitSet`](#type-LimitSet) | — | The limit set every value is read against.
Shown in: [Read latency against its objective](#/verdictcolumn/read-latency-against-its-objective), [Sort by verdict or by value](#/verdictcolumn/sort-by-verdict-or-by-value), [Judge a computed share](#/verdictcolumn/judge-a-computed-share) and 5 more | | `format` | [`NumberFormat`](#type-NumberFormat) | — | How the value and its excess are written.
Shown in: [Judge a computed share](#/verdictcolumn/judge-a-computed-share), [Show the worst in the footer](#/verdictcolumn/show-the-worst-in-the-footer), [Carry the worst verdict up (Aggregate)](#/aggregate/worst-verdict) and 1 more | | `sortBy` | `"verdict" \| "value"` | `"verdict"` | What the column sorts by: by the weight of the verdict (with equal weight the existing order stays) – or by the value.
Shown in: [Sort by verdict or by value](#/verdictcolumn/sort-by-verdict-or-by-value) | | `width` | `number` | the browser's | Initial width in pixels.
Shown in: [Pin columns to both sides (Width and pinning)](#/width-and-pinning/pinned-both-sides) | | `resizable` | `boolean` | — | Shows the drag grip on the header cell. | | `pin` | [`Pin`](#type-Pin)
`"start" \| "end"` | — | Keeps the column in view while the table scrolls sideways, before or after every other column.
Shown in: [Pin columns to both sides (Width and pinning)](#/width-and-pinning/pinned-both-sides) | | `aggregate` | `"worst"` | — | `"worst"`: the worst verdict among the rows – in the footer and in a group's header.
Shown in: [Show the worst in the footer](#/verdictcolumn/show-the-worst-in-the-footer), [Pin columns to both sides (Width and pinning)](#/width-and-pinning/pinned-both-sides), [Read a day's deliveries by depot (Grouping)](#/grouping/the-dispatch-report) and 2 more | ##### Types on this page ###### `LimitSet` From `@umriss-ui/core`. The set a value is read against. The target stands beside the limits and is not one: it is missed, never violated, and it is never the edge of a region. | Prop | Type | Default | Description | |---|---|---|---| | `limits` | `readonly `[`Limit`](#type-Limit)`[]` | — | In any order; none, one or several.
Shown in: [Read latency against its objective](#/verdictcolumn/read-latency-against-its-objective), [Sort by verdict or by value](#/verdictcolumn/sort-by-verdict-or-by-value), [Judge a computed share](#/verdictcolumn/judge-a-computed-share) and 5 more | | `target` | `number` | — |
Shown in: [Pin columns to both sides (Width and pinning)](#/width-and-pinning/pinned-both-sides), [Carry the worst verdict up (Aggregate)](#/aggregate/worst-verdict), [Review cost centres against budget (Scenarios)](#/scenarios/review-cost-centres) | ###### `NumberFormat` The names of the standard presentations of a number. ```ts type NumberFormat = "percent" | "count" | { decimals: number }; ``` ###### `Pin` Where a pinned column stands: in the block before or after the others. ```ts type Pin = "start" | "end"; ``` ###### `Limit` From `@umriss-ui/core`. A bound: a value, a side, a severity. No tolerance band as input. A band is derived - two limits of equal severity on opposite sides enclose one - and as an input type it would force a `null` on one edge in the one-sided case. | Prop | Type | Default | Description | |---|---|---|---| | `value` *required* | `number` | — |
Shown in: [Read latency against its objective](#/verdictcolumn/read-latency-against-its-objective), [Sort by verdict or by value](#/verdictcolumn/sort-by-verdict-or-by-value), [Judge a computed share](#/verdictcolumn/judge-a-computed-share) and 5 more | | `side` *required* | [`Side`](#type-Side)
`"upper" \| "lower"` | — |
Shown in: [Read latency against its objective](#/verdictcolumn/read-latency-against-its-objective), [Sort by verdict or by value](#/verdictcolumn/sort-by-verdict-or-by-value), [Judge a computed share](#/verdictcolumn/judge-a-computed-share) and 5 more | | `severity` *required* | [`Severity`](#type-Severity)
`"warning" \| "alarm"` | — |
Shown in: [Read latency against its objective](#/verdictcolumn/read-latency-against-its-objective), [Sort by verdict or by value](#/verdictcolumn/sort-by-verdict-or-by-value), [Judge a computed share](#/verdictcolumn/judge-a-computed-share) and 5 more | ###### `Side` From `@umriss-ui/core`. Whether a limit is an upper or a lower bound. ```ts type Side = "upper" | "lower"; ``` ###### `Severity` From `@umriss-ui/core`. How seriously a violation weighs: one severity that advises and one that calls for action. Exactly two; for a third distinction one sets a second limit. ```ts type Severity = "warning" | "alarm"; ``` #### Known limits - No list filter: four verdicts are sorted, not filtered. - It does not judge whether a value is stale; a stale value keeps its verdict ([ADR-0010](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0010-a-stale-value-keeps-its-verdict.md)). What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/). ## API index Every name the package exports, in one place. ### API index Everything `@umriss-ui/table` exports, with its signature. A component's props stand on its own page. Demo page: https://romanhaendler.github.io/umriss-ui/table/api/ #### Components ##### `ColumnMenu` [ColumnMenu](#/columnmenu) – A menu in which the user shows, hides, reorders and pins the table's columns and picks a grouping (also called a column chooser). Offer it where the table has more columns than everyone needs. Used on [Scenarios](#/), [Provider](#/provider), [Column](#/column), [Width and pinning](#/width-and-pinning), [Search](#/search), [Grouping](#/grouping), [Tree rows](#/tree-rows), [Toolbar](#/toolbar), [Toolbar controls](#/toolbar-controls), [ColumnMenu](#/columnmenu), [View](#/view) and [Server mode](#/server-mode). ##### `Export` [Export](#/export) – A button that writes the rows the user sees into a CSV file for a spreadsheet (also called download or export to Excel). Hand the application the text instead where it wants the clipboard or a file of its own. Used on [Scenarios](#/), [Provider](#/provider), [Sorting](#/sorting), [Search](#/search), [Grouping](#/grouping), [Toolbar](#/toolbar), [Toolbar controls](#/toolbar-controls), [Export](#/export) and [Server mode](#/server-mode). ##### `Pagination` [Pagination](#/pagination) – The bar under the table that splits long results into pages, with the page size to choose (also called a pager). The table pages only where one stands; without it every filtered row is shown. Used on [Scenarios](#/), [First table](#/first-table), [Filter](#/filter), [Pre-filter](#/pre-filter), [Grouping](#/grouping), [Toolbar](#/toolbar), [Toolbar controls](#/toolbar-controls), [Pagination](#/pagination), [Benchmark](#/benchmark) and [Server mode](#/server-mode). ##### `Search` [Search](#/search) – A text field that narrows the table to the rows containing what the user types (also called a quick filter or global filter). Put it in the toolbar, or anywhere on the page. Used on [Scenarios](#/), [First table](#/first-table), [Provider](#/provider), [Formats](#/formats), [Sorting](#/sorting), [Search](#/search), [Filter](#/filter), [Pre-filter](#/pre-filter), [Grouping](#/grouping), [Tree rows](#/tree-rows), [Aggregate](#/aggregate), [Selection](#/selection), [Row appearance](#/row-appearance), [RowDetail](#/rowdetail), [Toolbar](#/toolbar), [Toolbar controls](#/toolbar-controls), [Export](#/export), [View](#/view), [Pagination](#/pagination), [Virtualisation](#/virtualisation), [Benchmark](#/benchmark), [Server mode](#/server-mode), [Grid mode](#/grid-mode) and [Edits](#/edits). ##### `Toolbar` [Toolbar](#/toolbar) – The bar above the table that holds its search, column menu and export, shows how many rows match, and carries the bulk actions while rows are ticked (also called a table header bar). Used on [Scenarios](#/), [First table](#/first-table), [Provider](#/provider), [Column](#/column), [Width and pinning](#/width-and-pinning), [Sorting](#/sorting), [Search](#/search), [Filter](#/filter), [Pre-filter](#/pre-filter), [Grouping](#/grouping), [Tree rows](#/tree-rows), [Aggregate](#/aggregate), [Selection](#/selection), [Row appearance](#/row-appearance), [RowDetail](#/rowdetail), [RowActions](#/rowactions), [Toolbar](#/toolbar), [Toolbar controls](#/toolbar-controls), [ColumnMenu](#/columnmenu), [Export](#/export), [View](#/view), [Pagination](#/pagination), [Virtualisation](#/virtualisation), [Benchmark](#/benchmark), [Server mode](#/server-mode) and [Edits](#/edits). #### Hooks ##### `useTable` A table over your rows: the hook holds its state - sort, filters, search, grouping, page, selection, column widths -, and the parts it returns are typed at your row, so that a field name in `Column` is checked by the compiler. - `rows` – All rows in automatic mode; in server mode the page a server delivered for the current view. - `options` – How a row is keyed, the view to start from and the handler that hears every change of it, and in server mode the server's count and the handler that hears every request. - Returns The table's state and its parts: `Table`, `Column`, `VerdictColumn`, `RowDetail`, `RowActions`, `Action` and `GroupBy`. Pass it as `of` to a part that stands outside the table. ```ts function useTable(rows: readonly Z[], options: TableOptions): Table; ``` Used on [Scenarios](#/), [Installation](#/installation), [First table](#/first-table), [Provider](#/provider), [Column](#/column), [Formats](#/formats), [Width and pinning](#/width-and-pinning), [Presets](#/presets), [Sorting](#/sorting), [Search](#/search), [Filter](#/filter), [Pre-filter](#/pre-filter), [Grouping](#/grouping), [Tree rows](#/tree-rows), [Aggregate](#/aggregate), [Selection](#/selection), [Row appearance](#/row-appearance), [RowDetail](#/rowdetail), [RowActions](#/rowactions), [Toolbar](#/toolbar), [Toolbar controls](#/toolbar-controls), [ColumnMenu](#/columnmenu), [Export](#/export), [View](#/view), [Pagination](#/pagination), [Virtualisation](#/virtualisation), [Benchmark](#/benchmark), [Server mode](#/server-mode), [Grid mode](#/grid-mode), [Edits](#/edits) and [VerdictColumn](#/verdictcolumn). ##### `useTableSelection` A small headless helper for row selection: it holds the selection as a set and supplies the states for "select all" (including indeterminate). What the selection means in the domain stays the application's business. ```ts function useTableSelection(ids: readonly T[]): TableSelection; ``` Used on [Selection](#/selection). #### Functions ##### `column` A column preset that fits every table whose rows carry its field: create it once, outside the component, and spread it into a `Column`. With more than one field in `P`, name the field as the second type argument. ```ts function column = Field

>(preset: ColumnPreset): ColumnPreset; ``` Used on [Scenarios](#/), [First table](#/first-table), [Formats](#/formats), [Width and pinning](#/width-and-pinning), [Presets](#/presets), [Sorting](#/sorting), [Filter](#/filter), [Grouping](#/grouping), [Tree rows](#/tree-rows), [View](#/view), [Virtualisation](#/virtualisation), [Benchmark](#/benchmark), [Server mode](#/server-mode), [Grid mode](#/grid-mode) and [VerdictColumn](#/verdictcolumn). ##### `columnFilter` A filter for columns with values of type `W` and conditions of type `B`. The compiler accepts it at every column whose value fits, and rejects it otherwise. Create it outside the component, like a preset. ```ts function columnFilter(definition: ColumnFilter): ColumnFilter; ``` Used on [Filter](#/filter). ##### `rowFilter` A filter over the rows of type `Z` with conditions of type `B`. Create it outside the component, like a column filter, and name it in `rowFilters`. ```ts function rowFilter(definition: RowFilter): RowFilter; ``` Used on [Filter](#/filter) and [Toolbar controls](#/toolbar-controls). #### Types ##### `Absent` A value that is not there: `null` or `undefined`. ```ts type Absent = null | undefined; ``` Used on [Column](#/column). ##### `ActionProps` Its table stands on [RowActions](#/rowactions/type-ActionProps). ##### `AggregateColumn` A column that carries an aggregate, as the pipeline needs it. | Prop | Type | Default | Description | |---|---|---|---| | `id` *required* | `string` | — | | | `read` *required* | `(row: Z) => unknown` | — | | | `aggregate` *required* | [`AggregateKind`](#type-AggregateKind)` \| `[`OwnAggregate`](#type-OwnAggregate)`` | — | | ##### `AggregateFor` What a column accepts as `aggregate`: sums and averages for numbers, the extremes for numbers and points in time, the range for points in time, counts for every value with a text form - or a function of one's own. ```ts type AggregateFor = | ([Present] extends [number] ? "sum" | "avg" | "min" | "max" : never) | ([Present] extends [Date] ? "min" | "max" | "range" : never) | (IsDisplayable extends true ? "count" | "distinct" : never) | AggregateFunction; ``` ##### `AggregateFunction` An aggregate of one's own: the values present and the rows of the group - or of the filtered set, in the footer. Its result runs through the column's presentation; `undefined` means there is none. ```ts type AggregateFunction = (values: readonly Present>[], rows: readonly Z[]) => Present> | undefined; ``` ##### `AggregateKind` The built-in aggregates. `range` is for points in time: the earliest and the latest. ```ts type AggregateKind = "sum" | "avg" | "min" | "max" | "range" | "count" | "distinct"; ``` ##### `AggregateOptions` Its table stands on [Aggregate](#/aggregate/type-AggregateOptions). ##### `CellEdit` An edit the user committed - reported, not applied. | Prop | Type | Default | Description | |---|---|---|---| | `rowKey` *required* | `string` | — |
Shown in: [Combine grid mode with groups, pinning and many rows (Grid mode)](#/grid-mode/the-whole-grid), [Edit a comments column (Edits)](#/edits/comments-column), [Check an edit before it is reported (Edits)](#/edits/validated-edits) and 1 more | | `columnId` *required* | `string` | — |
Shown in: [Combine grid mode with groups, pinning and many rows (Grid mode)](#/grid-mode/the-whole-grid), [Check an edit before it is reported (Edits)](#/edits/validated-edits), [Plan the team's capacity (Scenarios)](#/scenarios/plan-the-team-capacity) | | `value` *required* | `unknown` | — | The editor's value: text for `"text"`, a number or null for `"number"`, a day or null for `"date"`, an option for `"select"`.
Shown in: [Combine grid mode with groups, pinning and many rows (Grid mode)](#/grid-mode/the-whole-grid), [Edit a comments column (Edits)](#/edits/comments-column), [Check an edit before it is reported (Edits)](#/edits/validated-edits) and 1 more | | `row` *required* | `Z` | — |
Shown in: [Check an edit before it is reported (Edits)](#/edits/validated-edits), [Plan the team's capacity (Scenarios)](#/scenarios/plan-the-team-capacity) | Used on [Edits](#/edits). ##### `CellEditorProps` What an editor of one's own receives. The table holds the draft; Enter, Escape and Tab stay the table's, bubbling up from the field. | Prop | Type | Default | Description | |---|---|---|---| | `value` *required* | `W \| null` | — | The draft - the cell's value until the field changes it. | | `commit` *required* | `(value?: W \| null) => void` | — | Commits - the draft, or the value given. A picker commits on its pick. | | `row` *required* | `Z` | — | | | `invalid` *required* | `boolean` | — | The draft did not validate: the field shows it; the message stands beneath. | | `label` *required* | `string` | — | The field's name: what is edited, for which row. The `FormField` around the editor already names a core field by it. | ###### Events | Prop | Type | Default | Description | |---|---|---|---| | `onChange` *required* | `(value: W \| null) => void` | — | Changes the draft. | ##### `ColumnBase` Its table stands on [Column](#/column/type-ColumnBase). ##### `ColumnComponent` The `Column` of a table, typed at its row: a field column names a field in `value`, a computed column a function and an `id`. What it accepts as `aggregate`, `filter`, `format` and `edit` follows from the type of its value. ```ts interface ColumnComponent { ( props: Computed & { footer: "sum" | "avg"; aggregate?: never; format?: NumberFormat; children?: Presentation; share?: boolean }, ): ReactNode; ( props: Computed & { aggregate: "sum" | "avg" | "min" | "max" | "count" | "distinct"; footer?: never; share?: boolean; format?: NumberFormat; children?: Presentation; }, ): ReactNode; ( props: Computed & { aggregate: "min" | "max" | "range" | "count" | "distinct"; footer?: never; format?: DateFormat; group?: DatePeriod; children?: Presentation; }, ): ReactNode; ( props: Computed & { group: DatePeriod; aggregate?: "min" | "max" | "range" | "count" | "distinct"; footer?: never; format?: DateFormat; children?: Presentation; }, ): ReactNode; ( props: Computed & { format: NumberFormat; aggregate?: AggregateFunction; share?: boolean; children?: Presentation }, ): ReactNode; ( props: Computed & { format: DateFormat; group?: DatePeriod; aggregate?: AggregateFunction; children?: Presentation }, ): ReactNode; ( props: Computed & { footer?: never; format?: never; aggregate?: AggregateFunction; children: Presentation; }, ): ReactNode; ( props: Computed & { footer?: never; format?: never; aggregate?: "count" | "distinct" | AggregateFunction; children?: never }, ): ReactNode; >(props: FieldColumn): ReactNode; } ``` ##### `ColumnFilter` Its table stands on [Filter](#/filter/type-ColumnFilter). ##### `ColumnMenuProps` Its table stands on [ColumnMenu](#/columnmenu/type-ColumnMenuProps). ##### `ColumnPreset = Field

>` The properties of a column, as `column()` supplies them. ```ts type ColumnPreset = Field

> = FieldColumn; ``` ##### `Computed` A computed column's props: its value is a function of the row, so it needs an `id`. ```ts type Computed = ColumnBase & ValuePaths & { /** The id is required where the value is a function. */ id: string; value: (row: Z) => W; /* The two names stand here without a condition on the value type, unlike on a field column: the value of a computed column is derived from `value`, and a condition over this W is not yet resolved when the overload is chosen - `filter="range"` would then leave the whole column untyped. A filter of one's own is still checked against the value. */ /** A filter in the column header: `"list"` offers the values that occur, `"range"` two bounds (meaningful only for numbers and points in time), a filter from `columnFilter` asks whatever it asks itself. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the condition belongs to the filter filter?: "list" | "range" | ColumnFilter, any>; /** Edits the column's cells in place in grid mode - see a field column's `edit`. */ edit?: "text" | "number" | "select" | "date" | ((editor: CellEditorProps) => ReactNode); /** What `edit="select"` offers. @default the values that occur */ editOptions?: readonly Present[]; /** Checks a draft before it is reported; a message keeps the editor open. */ validate?: (value: W | null, row: Z) => string | null | undefined; }; ``` ##### `DateFormat` The names of the standard presentations of a point in time. ```ts type DateFormat = "date" | "time" | "dateTime"; ``` ##### `DatePeriod` The span a point in time is grouped by. ```ts type DatePeriod = "day" | "week" | "month" | "year"; ``` ##### `Displayable` What can appear as text without `children`. ```ts type Displayable = string | number | boolean | Date; ``` ##### `EditFor` What a column accepts as `edit`: the core field for its value - `"text"` for text, `"number"` for numbers, `"date"` for points in time, `"select"` for text and numbers - or an editor of one's own. ```ts type EditFor = | ([Present] extends [string] ? "text" | "select" : never) | ([Present] extends [number] ? "number" | "select" : never) | ([Present] extends [Date] ? "date" : never) | ((editor: CellEditorProps) => ReactNode); ``` ##### `EditOptions` The props a column carries for editing in grid mode (``). | Prop | Type | Default | Description | |---|---|---|---| | `edit` | [`EditFor`](#type-EditFor)`` | — | Edits the column's cells in place in grid mode: Enter, F2 or typing starts it, Enter or Tab commits, Escape cancels. The table applies nothing - the edit is reported through the table's `onCellEdit`.
Shown in: [Share a column between tables (Presets)](#/presets/presets), [Combine grid mode with groups, pinning and many rows (Grid mode)](#/grid-mode/the-whole-grid), [Edit a comments column (Edits)](#/edits/comments-column) and 6 more | | `editOptions` | `readonly `[`Present`](#type-Present)`[]` | the values that occur | What `edit="select"` offers.
Shown in: [Share a column between tables (Presets)](#/presets/presets), [Check an edit before it is reported (Edits)](#/edits/validated-edits), [Save a row on purpose (Edits)](#/edits/save-a-row-on-purpose) and 1 more | | `validate` | `(value: W \| null, row: Z) => string \| null \| undefined` | — | Checks a draft before it is reported: a message keeps the editor open and stands beneath the field, as in a `FormField`; nothing lets it through.
Shown in: [Share a column between tables (Presets)](#/presets/presets), [Combine grid mode with groups, pinning and many rows (Grid mode)](#/grid-mode/the-whole-grid), [Check an edit before it is reported (Edits)](#/edits/validated-edits) and 3 more | ##### `ExportProps` Its table stands on [Export](#/export/type-ExportProps). ##### `Field` The field names of a row. ```ts type Field = Extract; ``` ##### `FieldColumn` Its table stands on [Column](#/column/type-FieldColumn). ##### `FieldCondition` What can be set as a condition on a field: a list of its values, and for numbers and points in time a range as well. ```ts type FieldCondition = | ListCondition | ([Present] extends [number] ? RangeCondition : [Present] extends [Date] ? RangeCondition : never); ``` ##### `FilterFor` What a column accepts as `filter`: `"list"` for every value with a text form, `"range"` for numbers and points in time, a filter from `columnFilter` only where its value type fits. ```ts type FilterFor = | (IsDisplayable extends true ? "list" : never) | ([Present] extends [number] ? "range" : [Present] extends [Date] ? "range" : never) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the condition belongs to the filter; at the column only the value counts | ColumnFilter, any>; ``` ##### `FilterInputProps` Its table stands on [Filter](#/filter/type-FilterInputProps). ##### `FormatFor` What a column accepts as `format`: a number format for numbers, a date format for points in time, nothing for every other value. ```ts type FormatFor = [Present] extends [number] ? NumberFormat : [Present] extends [Date] ? DateFormat : never; ``` ##### `GroupByBase` Its table stands on [Grouping](#/grouping/type-GroupByBase). ##### `GroupByComponent` A value the table can be grouped by without being a column: no cell, no export, no entry among the columns - only where a grouping is chosen. ```ts interface GroupByComponent { ( props: GroupByBase & { /** The id is required where the value is a function. */ id: string; value: (row: Z) => W; groupValue?: (value: Present) => string | number | boolean | Date | Absent; group?: GroupFor; }, ): ReactNode; >( props: GroupByBase & { /** A field of the row; supplies the id at the same time. */ value: K; id?: string; groupValue?: (value: Present) => string | number | boolean | Date | Absent; group?: GroupFor; }, ): ReactNode; } ``` ##### `GroupFor` `group` on a point in time: its rows are grouped by day, ISO week, month or year. ```ts type GroupFor = [Present] extends [Date] ? DatePeriod : never; ``` ##### `GroupingId` A column or group key the table can be grouped by. The columns stand in the JSX, which the hook does not see: a field name is suggested, any id is taken, and one that nothing carries falls out. ```ts type GroupingId = Field | (string & {}); ``` ##### `GroupLevel` One level of the grouping: what a row is grouped by on it. | Prop | Type | Default | Description | |---|---|---|---| | `id` *required* | `string` | — | | | `value` *required* | `(row: Z) => unknown` | — | The grouping value - the column's value, or what `groupValue` or a date period made of it. Absent values form one group of their own. | | `order` | `(row: Z) => unknown` | — | Where the groups of a key of one's own stand: by the smallest of this over their rows. Bands of a number are named "Small", "Large" and must not stand in the alphabet's order. | ##### `IsDisplayable` `true` where the value present can appear as text without `children` (`Displayable`), `false` otherwise - what decides the filters and aggregates a column offers. ```ts type IsDisplayable = [Present] extends [never] ? false : [Present] extends [Displayable] ? true : false; ``` ##### `ListCondition` The condition of a list filter: the chosen values, an absent one as `null`. ```ts type ListCondition = readonly (Present | null)[]; ``` ##### `ModelColumn` A column as the model reads it - what sorting, search, hiding and the export need of it, not its presentation. | Prop | Type | Default | Description | |---|---|---|---| | `id` *required* | `K` | — | | | `label` | `string` | — | Human-readable label – for the column menu and the CSV header row. Not for the presentation of the header cell: that stays compositional. | | `value` | `(row: Z) => string \| number \| null \| undefined` | — | The value for sorting and searching. `null`, `undefined` and `NaN` are absent: they stand last in both directions and match no search. | | `compare` | `(a: Z, b: Z) => number` | — | A comparison of one's own; otherwise by the kind of the value. | | `searchable` | `boolean` | `false` | Takes part in the free-text search. | | `hideable` | `boolean` | `true` | May the user switch this column away? `false` for columns that identify the row – without them the table is no longer readable. | | `resizable` | `boolean` | `false` | May the user drag the width? A column whose content fits anyway needs no grip. | | `width` | `number` | — | Initial width in pixels; without it the browser decides. | ##### `NumberField` The field names whose value is a number or may be absent. ```ts type NumberField = { [K in Field]: Z[K] extends number | Absent ? K : never; }[Field]; ``` ##### `NumberFormat` The names of the standard presentations of a number. ```ts type NumberFormat = "percent" | "count" | { decimals: number }; ``` ##### `OwnAggregate` An aggregate of one's own: the values present and the group's rows. ```ts type OwnAggregate = (values: readonly unknown[], rows: readonly Z[]) => unknown; ``` Used on [Aggregate](#/aggregate). ##### `PaginationProps` Its table stands on [Pagination](#/pagination/type-PaginationProps). ##### `Pin` Where a pinned column stands: in the block before or after the others. ```ts type Pin = "start" | "end"; ``` ##### `Present` The value without the absence - that is what `children` gets. ```ts type Present = Exclude; ``` ##### `Presentation` What a column draws in a cell: the value present and its row. ```ts type Presentation = (value: Present, row: Z) => ReactNode; ``` Used on [Formats](#/formats). ##### `RangeCondition` The condition of a range filter: both bounds inclusive, each may be absent. | Prop | Type | Default | Description | |---|---|---|---| | `from` | `W` | — | | | `to` | `W` | — | | ##### `RowActionsProps` Its table stands on [RowActions](#/rowactions/type-RowActionsProps). ##### `RowAdd` A new row the user saved: every column that edits, by column id. | Prop | Type | Default | Description | |---|---|---|---| | `values` *required* | `Readonly>` | — |
Shown in: [Keep a list: add, change and delete rows (Edits)](#/edits/keep-a-list) | ##### `RowAttributes` What a row carries in classes and data attributes. ```ts type RowAttributes = { className?: string } & { [data: `data-${string}`]: string | undefined; }; ``` ##### `RowDelete` A row the user asked to delete, and confirmed. | Prop | Type | Default | Description | |---|---|---|---| | `rowKey` *required* | `string` | — |
Shown in: [Keep a list: add, change and delete rows (Edits)](#/edits/keep-a-list), [Edit a dense table (Edits)](#/edits/edit-a-dense-table) | | `row` *required* | `Z` | — | | ##### `RowDetailProps` Its table stands on [RowDetail](#/rowdetail/type-RowDetailProps). ##### `RowFilter` Its table stands on [Filter](#/filter/type-RowFilter). ##### `RowGroup` The rows of the filtered set that share one grouping value on one level. | Prop | Type | Default | Description | |---|---|---|---| | `path` *required* | `string` | — | Identifies the group across renders and in the view: the keys of its path, outermost first. | | `value` *required* | `unknown` | — | The grouping value; `undefined` for the group of absent values. | | `level` *required* | `number` | — | 0 is the outermost level. | | `rows` *required* | `Z[]` | — | | | `groups` *required* | [`RowGroup`](#type-RowGroup)`[]` | — | The groups of the next level; empty on the innermost. | | `aggregates` *required* | `Record` | — | Per column id, what its aggregate comes to over this group's rows. | ##### `RowSave` A Row draft the user saved ([ADR-0036](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0036-a-grid-edits-on-the-first-click-and-saves-rows-on-purpose.md)) - reported, not applied. | Prop | Type | Default | Description | |---|---|---|---| | `rowKey` *required* | `string` | — |
Shown in: [Save a row on purpose (Edits)](#/edits/save-a-row-on-purpose), [Check a whole row before it is saved (Edits)](#/edits/check-a-whole-row), [Keep a list: add, change and delete rows (Edits)](#/edits/keep-a-list) and 1 more | | `changes` *required* | `Readonly>` | — | The columns the draft changed, by column id, with their editors' values.
Shown in: [Save a row on purpose (Edits)](#/edits/save-a-row-on-purpose), [Check a whole row before it is saved (Edits)](#/edits/check-a-whole-row), [Keep a list: add, change and delete rows (Edits)](#/edits/keep-a-list) and 1 more | | `row` *required* | `Z` | — |
Shown in: [Save a row on purpose (Edits)](#/edits/save-a-row-on-purpose) | Used on [Edits](#/edits). ##### `SearchProps` Its table stands on [Search](#/search/type-SearchProps). ##### `SetFilter` Sets a condition from outside. Typed at the field of the row, not at the column: the columns stand in the JSX, and the hook does not see that ([ADR-0017](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0017-columns-are-declared-by-composition.md)). An id that is not a field name – a computed column or an `id` of one's own – takes any condition; through it the condition of a filter of one's own is set as well. ```ts interface SetFilter { (filter: RowFilter, condition: B | null): void; >(column: K, condition: FieldCondition | null): void; (column: I & (I extends Field ? never : unknown), condition: unknown): void; } ``` ##### `SortDirection` The direction of one sort level: `asc` puts the smallest value on top, `desc` the largest. ```ts type SortDirection = "asc" | "desc"; ``` ##### `SortLevel` One level of the sort. Several levels act in their order: the second decides only what the first leaves open. | Prop | Type | Default | Description | |---|---|---|---| | `column` *required* | `K` | — |
Shown in: [Sort, arrange and export from controls of your own (First table)](#/first-table/sort-arrange-and-export-from-controls-of-your-own), [A label wider than its column (Width and pinning)](#/width-and-pinning/a-label-wider-than-its-column), [Sort by several columns (Sorting)](#/sorting/sort-by-several-columns) and 10 more | | `direction` *required* | [`SortDirection`](#type-SortDirection)
`"asc" \| "desc"` | — |
Shown in: [Sort, arrange and export from controls of your own (First table)](#/first-table/sort-arrange-and-export-from-controls-of-your-own), [A label wider than its column (Width and pinning)](#/width-and-pinning/a-label-wider-than-its-column), [Sort by several columns (Sorting)](#/sorting/sort-by-several-columns) and 10 more | ##### `Table` What `useTable` returns: the table's state, and its parts typed at the row. Pass it as `of` to a part that stands outside the table. | Prop | Type | Default | Description | |---|---|---|---| | `Table` *required* | `(props: `[`TableProps`](#/first-table/type-TableProps)`) => ReactNode` | — |
Shown in: [A first table (Installation)](#/installation/first-table), [Show a list of services (First table)](#/first-table/first-table), [Search and page from a header of your own (First table)](#/first-table/search-and-page-from-a-header-of-your-own) and 118 more | | `Column` *required* | [`ColumnComponent`](#type-ColumnComponent)`` | — |
Shown in: [A first table (Installation)](#/installation/first-table), [Show a list of services (First table)](#/first-table/first-table), [Search and page from a header of your own (First table)](#/first-table/search-and-page-from-a-header-of-your-own) and 118 more | | `VerdictColumn` *required* | [`VerdictColumnComponent`](#type-VerdictColumnComponent)`` | — |
Shown in: [Pin columns to both sides (Width and pinning)](#/width-and-pinning/pinned-both-sides), [Read a day's deliveries by depot (Grouping)](#/grouping/the-dispatch-report), [Carry the worst verdict up (Aggregate)](#/aggregate/worst-verdict) and 5 more | | `RowDetail` *required* | `(props: `[`RowDetailProps`](#/rowdetail/type-RowDetailProps)`) => ReactNode` | — |
Shown in: [Keep the header and the names in view (Width and pinning)](#/width-and-pinning/sticky-parts), [Employees in the detail (Tree rows)](#/tree-rows/employees-in-the-detail), [Own staff and teams (Tree rows)](#/tree-rows/own-staff-and-teams) and 5 more | | `RowActions` *required* | `(props: `[`RowActionsProps`](#/rowactions/type-RowActionsProps)`) => ReactNode` | — |
Shown in: [Name the row (Column)](#/column/row-header), [Pin columns to both sides (Width and pinning)](#/width-and-pinning/pinned-both-sides), [Select a whole group (Grouping)](#/grouping/a-whole-group-selected) and 7 more | | `Action` *required* | `(props: `[`ActionProps`](#/rowactions/type-ActionProps)`) => ReactNode` | — |
Shown in: [Name the row (Column)](#/column/row-header), [Pin columns to both sides (Width and pinning)](#/width-and-pinning/pinned-both-sides), [Select a whole group (Grouping)](#/grouping/a-whole-group-selected) and 7 more | | `GroupBy` *required* | [`GroupByComponent`](#type-GroupByComponent)`` | — |
Shown in: [Group by a value that is no column (Grouping)](#/grouping/a-key-that-is-no-column), [Group twenty thousand rows (Grouping)](#/grouping/twenty-thousand-rows), [Read a day's deliveries by depot (Grouping)](#/grouping/the-dispatch-report) | | `rows` *required* | `readonly Z[]` | — | All the rows passed in, including those the pre-filter does not admit. From `TableSnapshot`.
Shown in: [Search and page from a header of your own (First table)](#/first-table/search-and-page-from-a-header-of-your-own) | | `filtered` *required* | `readonly Z[]` | — | The filtered set: what the pre-filter admits and search and filters leave, across all pages. From `TableSnapshot`.
Shown in: [Select rows (Selection)](#/selection/selection), [Scroll twenty thousand rows (Virtualisation)](#/virtualisation/virtualisation) | | `visible` *required* | `readonly Z[]` | — | What is rendered right now: the page or the window. From `TableSnapshot`.
Shown in: [Scroll twenty thousand rows (Virtualisation)](#/virtualisation/virtualisation) | | `page` *required* | `number` | — | The page shown, from 1. Without `Pagination` always 1. From `TableSnapshot`.
Shown in: [Search and page from a header of your own (First table)](#/first-table/search-and-page-from-a-header-of-your-own) | | `pageCount` *required* | `number` | — | How many pages the filtered set makes. From `TableSnapshot`.
Shown in: [Search and page from a header of your own (First table)](#/first-table/search-and-page-from-a-header-of-your-own) | | `pageSize` *required* | `number` | — | Rows per page. From `TableSnapshot`.
Shown in: [Search and page from a header of your own (First table)](#/first-table/search-and-page-from-a-header-of-your-own) | | `setPage` *required* | `(page: number) => void` | — | Changes the page, clamped to those that exist. From `TableSnapshot`.
Shown in: [Search and page from a header of your own (First table)](#/first-table/search-and-page-from-a-header-of-your-own) | | `setPageSize` *required* | `(size: number) => void` | — | Sets the rows per page; back to page one. From `TableSnapshot`.
Shown in: [Search and page from a header of your own (First table)](#/first-table/search-and-page-from-a-header-of-your-own) | | `search` *required* | `string` | — | The search text. From `TableSnapshot`.
Shown in: [Search and page from a header of your own (First table)](#/first-table/search-and-page-from-a-header-of-your-own) | | `setSearch` *required* | `(search: string) => void` | — | Sets the search text; back to page one. From `TableSnapshot`.
Shown in: [Search and page from a header of your own (First table)](#/first-table/search-and-page-from-a-header-of-your-own) | | `sort` *required* | `readonly `[`SortLevel`](#type-SortLevel)`[]` | — | The sort levels, the first one first – only over columns that registered. From `TableSnapshot`.
Shown in: [Sort, arrange and export from controls of your own (First table)](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | | `toggleSort` *required* | `(column: string, additive?: boolean) => void` | — | Sorts by a column. With `additive` a level is added instead of replacing the sort. From `TableSnapshot`.
Shown in: [Sort, arrange and export from controls of your own (First table)](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | | `hidden` *required* | `readonly string[]` | — | The ids of the hidden columns. From `TableSnapshot`.
Shown in: [Sort, arrange and export from controls of your own (First table)](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | | `toggleColumn` *required* | `(column: string) => void` | — | Hides a column or shows it again. The row header stays. From `TableSnapshot`.
Shown in: [Sort, arrange and export from controls of your own (First table)](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | | `order` *required* | `readonly string[]` | — | The order of the columns as ids, as the user chose it. From `TableSnapshot`.
Shown in: [Sort, arrange and export from controls of your own (First table)](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | | `setOrder` *required* | `(order: readonly string[]) => void` | — | Reorders the columns – on header, body and footer at once. From `TableSnapshot`.
Shown in: [Sort, arrange and export from controls of your own (First table)](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | | `pinned` *required* | `Readonly>` | — | The pinned columns by id - the declared ones until the user chooses. From `TableSnapshot`.
Shown in: [Pin and size from controls of your own (Width and pinning)](#/width-and-pinning/pin-and-size-from-controls-of-your-own) | | `setPin` *required* | `(column: string, pin: `[`Pin`](#type-Pin)` \| null) => void` | — | Pins a column to the start or the end, or unpins it with `null`. From `TableSnapshot`.
Shown in: [Pin and size from controls of your own (Width and pinning)](#/width-and-pinning/pin-and-size-from-controls-of-your-own) | | `widths` *required* | `Readonly>` | — | The widths in pixels: those of the columns, and over them the dragged ones. From `TableSnapshot`.
Shown in: [Pin and size from controls of your own (Width and pinning)](#/width-and-pinning/pin-and-size-from-controls-of-your-own) | | `setWidth` *required* | `(column: string, width: number \| undefined) => void` | — | Sets the width of a column; `undefined` takes it back. From `TableSnapshot`.
Shown in: [Pin and size from controls of your own (Width and pinning)](#/width-and-pinning/pin-and-size-from-controls-of-your-own) | | `expanded` *required* | `readonly string[]` | — | The keys of the expanded rows. From `TableSnapshot`.
Shown in: [Open a detail from outside the table (RowDetail)](#/rowdetail/open-a-detail-from-outside) | | `toggleRow` *required* | `(key: string) => void` | — | Expands a row or collapses it. From `TableSnapshot`.
Shown in: [Open a detail from outside the table (RowDetail)](#/rowdetail/open-a-detail-from-outside) | | `filter` *required* | `Readonly>` | — | The conditions of the column filters and row filters by id, in the order in which they were set. Whatever fits no column and no row filter is absent. From `TableSnapshot`.
Shown in: [Set a condition from outside the table (Filter)](#/filter/conditions-from-outside) | | `setFilter` *required* | [`SetFilter`](#type-SetFilter)`` | — | Sets the condition of a column filter or a row filter; `null` clears it. Back to page one. A condition that does not fit the kind of the filter – a range on a list filter, a column without a filter, a row filter not in `rowFilters` – is passed over with a warning in development. From `TableSnapshot`.
Shown in: [Set a condition from outside the table (Filter)](#/filter/conditions-from-outside), [Filter by a field no column shows (Filter)](#/filter/a-field-no-column-shows), [Rows from a request (Filter)](#/filter/rows-from-a-request) and 5 more | | `conditionOf` *required* | `(filter: `[`RowFilter`](#/filter/type-RowFilter)`) => B \| null` | — | The condition of a row filter, as its type - `null` while it is lifted. From `TableSnapshot`.
Shown in: [Filter by a field no column shows (Filter)](#/filter/a-field-no-column-shows), [Rows from a request (Filter)](#/filter/rows-from-a-request), [Ask a question over several fields (Filter)](#/filter/a-question-over-several-fields) and 3 more | | `selection` *required* | [`TableSelection`](#type-TableSelection)`` | — | The selection – its own or the one handed in through `selection`. From `TableSnapshot`.
Shown in: [Select rows (Selection)](#/selection/selection), [Copy the selection (Export)](#/export/copy-the-selection), [Scroll twenty thousand rows (Virtualisation)](#/virtualisation/virtualisation) and 3 more | | `grouping` *required* | `readonly string[]` | — | The ids the table is grouped by, the outermost first – only those a groupable column or a group key carries, at most three. From `TableSnapshot`.
Shown in: [Group and fold from controls of your own (Grouping)](#/grouping/group-and-fold-from-controls-of-your-own) | | `setGrouping` *required* | `(grouping: readonly string[]) => void` | — | Groups by these columns or group keys; `[]` ungroups. Back to page one. From `TableSnapshot`.
Shown in: [Group and fold from controls of your own (Grouping)](#/grouping/group-and-fold-from-controls-of-your-own) | | `setView` *required* | `(view: `[`TableView`](#type-TableView)`) => void` | — | Goes to a whole view; what it leaves out goes back to where the table starts, and names no column carries fall out. Not reported through `onViewChange`: two tables kept in step by `onViewChange: (v) => other.setView(v)` in both directions do not answer each other. A view with the content already shown changes nothing. From `TableSnapshot`.
Shown in: [Keep and restore a view (View)](#/view/initial-view) | | `folded` *required* | `readonly string[]` | — | The paths of the folded groups that still occur. From `TableSnapshot`.
Shown in: [Group and fold from controls of your own (Grouping)](#/grouping/group-and-fold-from-controls-of-your-own) | | `toggleFold` *required* | `(path: string) => void` | — | Folds a group or unfolds it. A group of one row does not fold. From `TableSnapshot`.
Shown in: [Group and fold from controls of your own (Grouping)](#/grouping/group-and-fold-from-controls-of-your-own) | | `foldAll` *required* | `() => void` | — | Folds every group that has more than one row. From `TableSnapshot`.
Shown in: [Fold groups into a summary (Grouping)](#/grouping/the-overview) | | `unfoldAll` *required* | `() => void` | — | Unfolds every group. From `TableSnapshot`.
Shown in: [Fold groups into a summary (Grouping)](#/grouping/the-overview) | | `branches` *required* | `readonly string[]` | — | Tree rows: the keys of the open branches. From `TableSnapshot`.
Shown in: [Open branches from controls of your own (Tree rows)](#/tree-rows/open-branches-from-controls-of-your-own) | | `toggleBranch` *required* | `(key: string) => void` | — | Tree rows: opens a branch or closes it. From `TableSnapshot`.
Shown in: [Open branches from controls of your own (Tree rows)](#/tree-rows/open-branches-from-controls-of-your-own) | | `unfoldAllBranches` *required* | `() => void` | — | Tree rows: opens every branch that has children. From `TableSnapshot`.
Shown in: [Open branches from controls of your own (Tree rows)](#/tree-rows/open-branches-from-controls-of-your-own) | | `foldAllBranches` *required* | `() => void` | — | Tree rows: closes every branch. From `TableSnapshot`.
Shown in: [Open branches from controls of your own (Tree rows)](#/tree-rows/open-branches-from-controls-of-your-own) | | `view` *required* | [`TableView`](#type-TableView) | — | The part of the state an application can keep; whatever still stands as the options set it up is absent. From `TableSnapshot`.
Shown in: [Set a condition from outside the table (Filter)](#/filter/conditions-from-outside), [Fold groups into a summary (Grouping)](#/grouping/the-overview), [Keep and restore a view (View)](#/view/initial-view) | | `asCsv` *required* | `() => string` | — | The filtered set in the visible columns as text for a spreadsheet - in server mode the page. From `TableSnapshot`.
Shown in: [Sort, arrange and export from controls of your own (First table)](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | | `selectionAsText` *required* | `() => string` | — | The selected rows within the filtered set as tab-separated text under a header line - the text Ctrl+C writes. `""` with nothing selected. From `TableSnapshot`.
Shown in: [Copy the selection (Export)](#/export/copy-the-selection) | | `virtual` *required* | `boolean` | — | Whether it virtualises. From `TableSnapshot`.
Shown in: [Search and page from a header of your own (First table)](#/first-table/search-and-page-from-a-header-of-your-own) | | `rowCount` *required* | `number` | — | How many rows the filtered set has - in server mode the server's count. From `TableSnapshot`.
Shown in: [Search and page from a header of your own (First table)](#/first-table/search-and-page-from-a-header-of-your-own) | | `server` *required* | `boolean` | — | Whether it is in server mode: the rows are one page of a server's. From `TableSnapshot`.
Shown in: [Sort, arrange and export from controls of your own (First table)](#/first-table/sort-arrange-and-export-from-controls-of-your-own) | Used on [Scenarios](#/), [Installation](#/installation), [First table](#/first-table), [Provider](#/provider), [Column](#/column), [Formats](#/formats), [Width and pinning](#/width-and-pinning), [Presets](#/presets), [Sorting](#/sorting), [Search](#/search), [Filter](#/filter), [Pre-filter](#/pre-filter), [Grouping](#/grouping), [Tree rows](#/tree-rows), [Aggregate](#/aggregate), [Selection](#/selection), [Row appearance](#/row-appearance), [RowDetail](#/rowdetail), [RowActions](#/rowactions), [Toolbar](#/toolbar), [Toolbar controls](#/toolbar-controls), [ColumnMenu](#/columnmenu), [Export](#/export), [View](#/view), [Pagination](#/pagination), [Virtualisation](#/virtualisation), [Benchmark](#/benchmark), [Server mode](#/server-mode), [Grid mode](#/grid-mode), [Edits](#/edits) and [VerdictColumn](#/verdictcolumn). ##### `TableInput` The view's state the model computes a table from, beside its rows and columns: search, filter, sort, page, hidden columns, order, grouping, tree. | Prop | Type | Default | Description | |---|---|---|---| | `search` | `string` | — | | | `filter` | `(row: Z) => boolean` | — | An additional filter beside the free-text search, e.g. a status choice. | | `sort` | [`SortLevel`](#type-SortLevel)` \| readonly `[`SortLevel`](#type-SortLevel)`[] \| null` | — | One level or several. A single level means the same as a list holding only it. | | `page` | `number` | — | 1-based; is clamped to the valid range. | | `pageSize` | `number` | — | | | `hidden` | `readonly K[]` | — | Ids of columns switched away. Columns that are not hideable stay. | | `order` | `readonly K[]` | — | The wanted column order. Columns not named follow in their natural order; unknown ids are passed over. | | `grouping` | `{ levels: readonly `[`GroupLevel`](#type-GroupLevel)`[]; aggregates?: readonly `[`AggregateColumn`](#type-AggregateColumn)`[]; folded: ReadonlySet; /** The collation of text keys; the provider's, where there is one. */ compareText?: (a: string, b: string) => number; }` | — | Groups the sorted filtered set; a page then counts lines, not rows. | | `server` | `{ rowCount: number }` | — | Server mode: the rows are the page a server answered, and `rowCount` is its filtered set. The model then searches, filters, sorts, groups and pages nothing - it would do it over one page and call that the whole. | | `tree` | [`TreeInput`](#type-TreeInput)`` | — | Tree rows: the rows are the roots, and these read their children. The model then sorts every level on its own, filters by core's path rule and pages nothing. | ##### `TableOptions` Its table stands on [First table](#/first-table/type-TableOptions). ##### `TableProps` Its table stands on [First table](#/first-table/type-TableProps). ##### `TableRef` What a part without a row type needs from a table – for `of`. ```ts type TableRef = Omit, "setFilter" | "conditionOf"> & { readonly Table: unknown; /** Without a row kind: any id, any condition. */ setFilter(column: string, condition: unknown): void; }; ``` Used on [First table](#/first-table). ##### `TableRequest` The request server mode reports to `onRequest`: the parts of the view that decide the rows - search, conditions, sort, page and page size - and nothing else, each at its default as well. A server has no default of the table's to fall back on, and a request it has to complete is one it completes wrongly. A width, an order, a pin or a fold is no part of it. ```ts type TableRequest = Required, "search" | "conditions" | "sort" | "page" | "pageSize">>; ``` Used on [Server mode](#/server-mode). ##### `TableSelection` What `useTableSelection` returns: the selected keys and the gestures on them. Give it to `useTable` as `selection` when the application holds the selection. | Prop | Type | Default | Description | |---|---|---|---| | `selected` *required* | `ReadonlySet` | — | The keys currently selected.
Shown in: [Work through the alerts (Scenarios)](#/scenarios/work-through-the-alerts) | | `isSelected` *required* | `(id: T) => boolean` | — |
Shown in: [Select rows (Selection)](#/selection/selection), [Hold the selection yourself (Selection)](#/selection/hold-the-selection-yourself) | | `toggle` *required* | `(id: T) => void` | — |
Shown in: [Hold the selection yourself (Selection)](#/selection/hold-the-selection-yourself) | | `toggleAll` *required* | `() => void` | — | Selects all the keys passed in - or, when all of them are selected already, deselects them. Keys outside them stay as they are. | | `clear` *required* | `() => void` | — |
Shown in: [Hold the selection yourself (Selection)](#/selection/hold-the-selection-yourself), [Work through the alerts (Scenarios)](#/scenarios/work-through-the-alerts), [Find a late shipment (Scenarios)](#/scenarios/find-a-late-shipment) | | `allSelected` *required* | `boolean` | — | true when every key passed in is selected. | | `someSelected` *required* | `boolean` | — | true when some - but not all - are selected. | | `count` *required* | `number` | — |
Shown in: [Select rows (Selection)](#/selection/selection), [Hold the selection yourself (Selection)](#/selection/hold-the-selection-yourself), [Copy the selection (Export)](#/export/copy-the-selection) and 2 more | ##### `TableSnapshot` Its table stands on [First table](#/first-table/type-TableSnapshot). ##### `TableView` Search, conditions, sort levels, page, page size, hidden columns, order, dragged widths, the grouping and the pinned columns. Whatever is at its default is absent. The pre-filter never belongs to it: it is a function of the application. | Prop | Type | Default | Description | |---|---|---|---| | `search` | `string` | — |
Shown in: [Show loading, empty, failed and no match (Row appearance)](#/row-appearance/states), [Hand the table one page (Server mode)](#/server-mode/only-pages), [Sort and search on the server (Server mode)](#/server-mode/sort-and-search) and 3 more | | `conditions` | `Readonly>` | — | The conditions of the column filters, per column. A condition for a column that does not exist, or whose filter is of another kind, falls out.
Shown in: [Ask a question over several fields (Filter)](#/filter/a-question-over-several-fields), [A dispatcher's bar (Toolbar controls)](#/toolbar-controls/a-dispatcher-s-bar), [Hand the table one page (Server mode)](#/server-mode/only-pages) and 4 more | | `sort` | `readonly `[`SortLevel`](#type-SortLevel)`[]` | — |
Shown in: [Keep and restore a view (View)](#/view/initial-view), [Hand the table one page (Server mode)](#/server-mode/only-pages), [Sort and search on the server (Server mode)](#/server-mode/sort-and-search) and 3 more | | `page` | `number` | — |
Shown in: [Hand the table one page (Server mode)](#/server-mode/only-pages), [Sort and search on the server (Server mode)](#/server-mode/sort-and-search), [Name the values a list filter offers (Server mode)](#/server-mode/filter-options) and 2 more | | `pageSize` | `number` | — |
Shown in: [Hand the table one page (Server mode)](#/server-mode/only-pages), [Sort and search on the server (Server mode)](#/server-mode/sort-and-search), [Name the values a list filter offers (Server mode)](#/server-mode/filter-options) and 2 more | | `hidden` | `readonly K[]` | — |
Shown in: [Keep and restore a view (View)](#/view/initial-view) | | `order` | `readonly K[]` | — | | | `widths` | `Readonly>` | — | Column widths the user has dragged, in pixels. | | `grouping` | `readonly K[]` | — | The columns and group keys the table is grouped by, the outermost first. | | `folded` | `readonly string[]` | — | The paths of the folded groups. A path that no longer occurs falls out.
Shown in: [Fold groups into a summary (Grouping)](#/grouping/the-overview) | | `branches` | `readonly string[]` | — | Tree rows: the keys of the open branches. A key that no longer occurs falls out. | | `pinned` | `Readonly>` | — | The pinned columns, whole, once the user's choice deviates from what the columns declare - `{}` when every declared pin was undone. A column that does not exist falls out. | Used on [View](#/view). ##### `ToolbarProps` Its table stands on [Toolbar](#/toolbar/type-ToolbarProps). ##### `ToolbarSize` The size of a toolbar's controls. ```ts type ToolbarSize = "sm" | "md"; ``` ##### `TreeInput` Tree rows for the model: how a row is keyed, how its children are read, and which branches are open. | Prop | Type | Default | Description | |---|---|---|---| | `key` *required* | `(row: Z) => string` | — | | | `children` *required* | `(row: Z) => readonly Z[] \| undefined` | — | `undefined` a leaf, an array a branch - the rule of core's reader. | | `open` *required* | `ReadonlySet` | — | The keys of the open branches. | | `admit` | `(row: Z) => boolean` | — | The pre-filter: a row it does not admit is gone with its subtree. | ##### `ValuePaths` Its table stands on [Column](#/column/type-ValuePaths). ##### `VerdictBase` Its table stands on [VerdictColumn](#/verdictcolumn/type-VerdictBase). ##### `VerdictColumnComponent` The `VerdictColumn` of a table, typed at its row: a numeric field, or a function with an `id`, whose value is read against `limits` and shown with its verdict. ```ts interface VerdictColumnComponent { (props: VerdictBase & { id: string; value: (row: Z) => number | Absent }): ReactNode; >(props: VerdictBase & { value: K; id?: string }): ReactNode; } ```