Rows and columns
The shortest list: headers sort, a press anywhere on a row opens it.
| Service | Minutes | Price |
|---|---|---|
| General check-up | 30 | 80000 |
| Dental cleaning | 45 | 120000 |
| Vaccination | 15 | 45000 |
import { ListTable } from '@arbol-ai/ui';
import type { DataTableColumn } from '@arbol-ai/ui';
interface Service {
id: string;
name: string;
minutes: number;
price: number;
}
const SERVICES: Service[] = [
{ id: 's1', name: 'General check-up', minutes: 30, price: 80_000 },
{ id: 's2', name: 'Dental cleaning', minutes: 45, price: 120_000 },
{ id: 's3', name: 'Vaccination', minutes: 15, price: 45_000 },
];
const SERVICE_COLUMNS: DataTableColumn<Service>[] = [
{ accessorKey: 'name', header: 'Service', meta: { fill: true } },
{ accessorKey: 'minutes', header: 'Minutes', size: 110, meta: { align: 'end' } },
{ accessorKey: 'price', header: 'Price', size: 120, meta: { align: 'end', cellClassName: 'tabular-nums' } },
];
<ListTable
aria-label="Services"
columns={SERVICE_COLUMNS}
data={SERVICES}
getRowHref={(service) => `#/services/${service.id}`}
/>A list the browser holds
A team or a catalog read whole: the search, the status chips and the pages run in the browser, and the rows have menus, selection and a bulk action.
| Patient | Status | Visits | Actions | |
|---|---|---|---|---|
CC 1.020.300 | Active | 0 | ||
CC 1.028.219 | Active | 5 | ||
CC 1.036.138 | Active | 10 | ||
CC 1.044.057 | Active | 2 | ||
CC 1.051.976 | Active | 7 | ||
CC 1.059.895 | Archived | 12 | ||
CC 1.067.814 | Active | 4 | ||
CC 1.075.733 | Active | 9 | ||
CC 1.083.652 | Active | 1 | ||
CC 1.091.571 | Active | 6 | ||
CC 1.099.490 | Active | 11 | ||
CC 1.107.409 | Archived | 3 | ||
CC 1.115.328 | Active | 8 | ||
CC 1.123.247 | Active | 0 | ||
CC 1.131.166 | Active | 5 |
import { DownloadSimpleIcon, UsersIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Avatar, Badge, Button, EmptyState, FilterChips, ListTable, RowTitle } from '@arbol-ai/ui';
import type { DataTableColumn } from '@arbol-ai/ui';
type PatientStatus = 'active' | 'archived';
interface Patient {
id: string;
name: string;
document: string;
status: PatientStatus;
visits: number;
}
const PATIENT_NAMES = [
'Ana Torres',
'Luis Gómez',
'Marta Ruiz',
'Carlos Peña',
'Sofía Rojas',
'Juan Castro',
'Laura Mejía',
'Pedro Díaz',
'Valentina Ortiz',
'Andrés Vargas',
'Camila Herrera',
'Diego Morales',
'Isabel Navarro',
'Jorge Salazar',
'Lucía Romero',
'Miguel Cárdenas',
'Paula Ríos',
];
const PATIENTS: Patient[] = PATIENT_NAMES.map((name, index) => ({
id: `p${index + 1}`,
name,
document: `CC ${(1_020_300 + index * 7919).toLocaleString('es-CO')}`,
status: index % 6 === 5 ? 'archived' : 'active',
visits: (index * 5) % 13,
}));
const PATIENT_COLUMNS: DataTableColumn<Patient>[] = [
{
accessorKey: 'name',
header: 'Patient',
cell: ({ row }) => (
<RowTitle
description={row.original.document}
href={`#/patients/${row.original.id}`}
leading={<Avatar name={row.original.name} />}
>
{row.original.name}
</RowTitle>
),
meta: { fill: true },
},
{
accessorKey: 'status',
header: 'Status',
size: 120,
cell: ({ row }) =>
row.original.status === 'active' ? <Badge tone="success">Active</Badge> : <Badge>Archived</Badge>,
meta: { minTableWidth: 560 },
},
{
accessorKey: 'visits',
header: 'Visits',
size: 100,
meta: { align: 'end', cellClassName: 'tabular-nums' },
},
];
const PatientsExample = () => {
const [status, setStatus] = useState<PatientStatus | null>(null);
const [pageNumber, setPageNumber] = useState(1);
return (
<ListTable
aria-label="Patients"
renderBulkActions={() => (
<Button size="sm" variant="outline">
<DownloadSimpleIcon aria-hidden /> Export
</Button>
)}
chips={
<FilterChips
label="Status"
onChange={(next) => {
setStatus(next);
setPageNumber(1);
}}
options={[
{ label: 'Active', value: 'active' },
{ label: 'Archived', value: 'archived' },
]}
value={status}
/>
}
columns={PATIENT_COLUMNS}
data={status === null ? PATIENTS : PATIENTS.filter((patient) => patient.status === status)}
emptyState={
<EmptyState
actions={<Button>New patient</Button>}
description="Everyone the clinic books or registers appears here."
icon={<UsersIcon aria-hidden />}
title="No patients yet"
/>
}
getRowHref={(patient) => `#/patients/${patient.id}`}
getRowId={(patient) => patient.id}
getSearchText={(patient) => `${patient.name} ${patient.document}`}
isFiltered={status !== null}
onClearFilters={() => {
setStatus(null);
}}
pagination={{ onPage: setPageNumber, page: pageNumber }}
rowActions={(patient) => [
{ label: 'Book an appointment', href: `#/book?patient=${patient.id}` },
{ label: 'Archive', isDestructive: true, onAction: () => undefined },
]}
rowActionsLabel={(patient) => `Actions for ${patient.name}`}
searchPlaceholder="Name or document"
selectionMode="multiple"
/>
);
};
<PatientsExample />A list the server pages
The search, the agent filter, the sort and the cursor go to the server; while the next page loads, the rows dim and the pressed button waits. The person’s columns are kept by the app.
| Contact | Agent | Started |
|---|---|---|
| Ana Torres | Front desk | 2026-11-07 09:12 |
| Luis Gómez | Reminders | 2026-11-07 08:47 |
import type { SortingState } from '@tanstack/react-table';
import { useState } from 'react';
import { FilterSelect, ListTable } from '@arbol-ai/ui';
import type { ColumnState, DataTableColumn } from '@arbol-ai/ui';
interface Conversation {
id: string;
contact: string;
agent: string;
started: string;
}
const CONVERSATIONS: Conversation[] = [
{ id: 'v1', contact: 'Ana Torres', agent: 'Front desk', started: '2026-11-07 09:12' },
{ id: 'v2', contact: 'Luis Gómez', agent: 'Reminders', started: '2026-11-07 08:47' },
{ id: 'v3', contact: 'Marta Ruiz', agent: 'Front desk', started: '2026-11-06 17:30' },
{ id: 'v4', contact: 'Carlos Peña', agent: 'Reminders', started: '2026-11-06 16:02' },
];
const CONVERSATION_COLUMNS: DataTableColumn<Conversation>[] = [
{ accessorKey: 'contact', header: 'Contact', meta: { fill: true } },
{ accessorKey: 'agent', enableSorting: false, header: 'Agent', size: 140 },
{
accessorKey: 'started',
header: 'Started',
size: 150,
meta: { align: 'end', cellClassName: 'tabular-nums' },
},
];
const AGENTS = [
{ value: 'Front desk', label: 'Front desk', description: 'Answers every call' },
{ value: 'Reminders', label: 'Reminders', description: 'Confirms appointments' },
];
const NO_VALUES: string[] = [];
const ConversationsExample = () => {
const [q, setQ] = useState('');
const [agents, setAgents] = useState<readonly string[]>(NO_VALUES);
const [sorting, setSorting] = useState<SortingState>([{ id: 'started', desc: true }]);
const [cursor, setCursor] = useState(0);
const [columns, setColumns] = useState<Partial<ColumnState>>({});
const [isUpdating, setIsUpdating] = useState(false);
/** What the server would do: answer a moment later. */
const ask = (change: () => void) => {
change();
setIsUpdating(true);
setTimeout(() => {
setIsUpdating(false);
}, 900);
};
const needle = q.trim().toLowerCase();
const rows = CONVERSATIONS.filter(
(conversation) =>
(needle === '' || conversation.contact.toLowerCase().includes(needle)) &&
(agents.length === 0 || agents.includes(conversation.agent))
).toSorted((a, b) => {
const order =
sorting.at(0)?.id === 'contact'
? a.contact.localeCompare(b.contact)
: a.started.localeCompare(b.started);
return sorting.at(0)?.desc === true ? -order : order;
});
return (
<ListTable
activeFilterCount={agents.length > 0 ? 1 : 0}
aria-label="Conversations"
columnState={columns}
columns={CONVERSATION_COLUMNS}
data={rows.slice(cursor * 2, cursor * 2 + 2)}
filters={
<FilterSelect
allLabel="All agents"
label="Agent"
onChange={(next) => {
ask(() => {
setAgents(next);
setCursor(0);
});
}}
options={AGENTS}
value={agents}
/>
}
getRowId={(conversation) => conversation.id}
isUpdating={isUpdating}
manualSorting
onClearFilters={() => {
ask(() => {
setAgents(NO_VALUES);
setCursor(0);
});
}}
onColumnStateChange={setColumns}
onRowAction={() => undefined}
onSearchChange={(next) => {
ask(() => {
setQ(next);
setCursor(0);
});
}}
onSortingChange={(next) => {
ask(() => {
setSorting(next);
setCursor(0);
});
}}
pagination={{
hasNextPage: rows.length > (cursor + 1) * 2,
hasPreviousPage: cursor > 0,
onNext: () => {
ask(() => {
setCursor((current) => current + 1);
});
},
onPrevious: () => {
ask(() => {
setCursor((current) => current - 1);
});
},
}}
searchDebounceMs={300}
searchPlaceholder="Search by contact"
searchValue={q}
sorting={sorting}
/>
);
};
<ConversationsExample />Source
The examples above are src/blocks/list-table/list-table.examples.tsx, which the library’s tests render too.