COMPONENTS/DATA DISPLAY
Data table.
Useful data, ready to work with.
In practice
| Coal | 4 | |
| Atlas | 12 | |
| Studio | 2 |
Supply rows, typed columns and stable getRowId. Includes client-side filtering, sorting, page selection and pagination. Intended for modest datasets; no virtualization or server queries.
Installation
Install Coal once. Components and TypeScript declarations are included. React 19 and React DOM 19 are peer dependencies.
Terminal · local package
npm install http://localhost:3100/downloads/chlohal-coal-ui-0.5.0.tgzThis installs the local package. It is not yet published on npm. See installation for stylesheet setup and integration.
Usage
example.tsx
"use client";
import * as React from "react";
import "@chlohal/coal-ui/styles.css";
import { DataTable } from "@chlohal/coal-ui";
export default function Example() {
const id = React.useId();
return (<DataTable caption="Projects" rows={[
{ id: "1", name: "Coal", members: 4 },
{ id: "2", name: "Atlas", members: 12 },
{ id: "3", name: "Studio", members: 2 },
{ id: "4", name: "Paper", members: 7 },
]} columns={[
{ id: "name", header: "Name", value: (row) => row.name },
{ id: "members", header: "Members", value: (row) => row.members },
]} getRowId={(row) => row.id} pageSize={3} selectable/>);
}
Source
The original Coal implementation. Use the package export to integrate it.
packages/react/src/data-table.tsx
"use client";
import * as React from "react";
import { cn } from "./internal.js";
import { Checkbox } from "./checkbox.js";
import { Pagination } from "./pagination.js";
export type DataTableColumn<T> = {
id: string;
header: string;
value: (row: T) => string | number;
render?: (row: T) => React.ReactNode;
sortable?: boolean;
};
export type DataTableProps<T> = {
rows: T[];
columns: DataTableColumn<T>[];
getRowId: (row: T) => string;
caption: string;
pageSize?: number;
selectable?: boolean;
onSelectionChange?: (ids: string[]) => void;
className?: string;
};
export function DataTable<T>({
rows,
columns,
getRowId,
caption,
pageSize = 5,
selectable = false,
onSelectionChange,
className,
}: DataTableProps<T>) {
const [query, setQuery] = React.useState("");
const [sort, setSort] = React.useState<{
id: string;
descending: boolean;
} | null>(null);
const [page, setPage] = React.useState(1);
const [selection, setSelection] = React.useState<string[]>([]);
const selected = selection.filter((id) =>
rows.some((row) => getRowId(row) === id),
);
const size = Math.max(1, Math.floor(pageSize) || 5);
const filtered = rows.filter((row) =>
columns.some((col) =>
String(col.value(row))
.toLocaleLowerCase()
.includes(query.toLocaleLowerCase()),
),
);
const column = columns.find((c) => c.id === sort?.id);
if (column && sort)
filtered.sort((a, b) => {
const x = column.value(a),
y = column.value(b);
return (
(typeof x === "number" && typeof y === "number"
? x - y
: String(x).localeCompare(String(y), undefined, { numeric: true })) *
(sort.descending ? -1 : 1)
);
});
const pages = Math.max(1, Math.ceil(filtered.length / size));
const current = Math.min(page, pages);
const visible = filtered.slice((current - 1) * size, current * size);
function update(ids: string[]) {
setSelection(ids);
onSelectionChange?.(ids);
}
const all =
visible.length > 0 && visible.every((r) => selected.includes(getRowId(r)));
return (
<div className={cn("coal-data-table", className)}>
<input
className="coal-input"
type="search"
aria-label={`Filter ${caption}`}
placeholder="Filter rows…"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
/>
<div className="coal-table-container">
<table className="coal-table">
<caption>{caption}</caption>
<thead>
<tr>
{selectable && (
<th scope="col">
<Checkbox
aria-label="Select visible rows"
checked={all}
indeterminate={
!all &&
visible.some((r) => selected.includes(getRowId(r)))
}
onCheckedChange={(checked) =>
update(
checked
? Array.from(
new Set([...selected, ...visible.map(getRowId)]),
)
: selected.filter(
(id) => !visible.some((r) => getRowId(r) === id),
),
)
}
/>
</th>
)}
{columns.map((col) => (
<th
key={col.id}
scope="col"
aria-sort={
sort?.id === col.id
? sort.descending
? "descending"
: "ascending"
: undefined
}
>
{col.sortable === false ? (
col.header
) : (
<button
type="button"
onClick={() => {
setSort({
id: col.id,
descending:
sort?.id === col.id ? !sort.descending : false,
});
setPage(1);
}}
>
{col.header}
<span aria-hidden="true">
{" "}
{sort?.id === col.id
? sort.descending
? "↓"
: "↑"
: "↕"}
</span>
</button>
)}
</th>
))}
</tr>
</thead>
<tbody>
{visible.map((row) => (
<tr
key={getRowId(row)}
data-selected={selected.includes(getRowId(row)) || undefined}
>
{selectable && (
<td>
<Checkbox
aria-label={`Select row ${getRowId(row)}`}
checked={selected.includes(getRowId(row))}
onCheckedChange={(checked) =>
update(
checked
? [...selected, getRowId(row)]
: selected.filter((id) => id !== getRowId(row)),
)
}
/>
</td>
)}
{columns.map((col) => (
<td key={col.id}>
{col.render ? col.render(row) : col.value(row)}
</td>
))}
</tr>
))}
{!visible.length && (
<tr>
<td colSpan={columns.length + (selectable ? 1 : 0)}>
No results.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="coal-data-table-footer">
<span role="status">
{filtered.length} results
{selectable ? ` · ${selected.length} selected` : ""}
</span>
<Pagination page={current} pageCount={pages} onPageChange={setPage} />
</div>
</div>
);
}
Accessibility checklist
- Provide an accessible name for controls, including icon-only buttons.
- Check keyboard navigation, visible focus and disabled states in your application.
- Do not rely on color alone to communicate status.