Skip to content

ListTable

A resource’s whole list from rows, columns and callbacks: the toolbar (search, filters, saved views, chips, columns), sortable headers, rows that open their record, row menus, every state inside the frame, pages by number or cursor, and a bar for the selected rows. Built from ListToolbar, DataTable, RowTitle, RowMenu, EmptyState, Pagination and SelectionBar, each usable alone.

Language
Appearance

Rows and columns

The shortest list: headers sort, a press anywhere on a row opens it.

ServiceMinutesPrice
General check-up3080000
Dental cleaning45120000
Vaccination1545000
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.

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.

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.

Type to search…

↑↓ navigate↵ selectEsc close