Data table
Rows of records in columns that the crew sort, filter, page through and select.
On this page
Code
import { AisTargetRow, type AisShipType } from "@bosunkit/components/ais-target-row";
import { Badge } from "@bosunkit/components/badge";
import { DataTable, type Column, type ColumnSort } from "@bosunkit/components/data-table";
import { priorityBadgeVariant, type Priority } from "@bosunkit/components/priority";
import { Fish, Sailboat, Ship, ShipWheel, type LucideIcon } from "lucide-react";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"The AIS target list at 14:02 UTC off Ushant: targets sorted by alert and then by CPA, a search field over every " +
"column, an alert and a vessel-name filter, checkboxes for a set of targets, five targets a page, a Columns " +
"control that shows MMSI, type (with its own filter), SOG and the last-seen time, ship-type icons, priority " +
"badges and wrapped notes. Type a name nothing matches to see the no-match state.";
type ShipType = "Cargo" | "Tanker" | "Passenger" | "Fishing" | "Sailing" | "Tug";
const TYPE_ICONS: Readonly<Record<ShipType, LucideIcon>> = {
Cargo: Ship,
Tanker: Ship,
Passenger: Ship,
Fishing: Fish,
Sailing: Sailboat,
Tug: ShipWheel,
};
const PRIORITY_WORDS: Readonly<Record<Priority, string>> = {
emergency: "Emergency",
alarm: "Alarm",
warning: "Warning",
caution: "Caution",
none: "No alert",
};
const PRIORITY_RANK: Readonly<Record<Priority, number>> = { emergency: 0, alarm: 1, warning: 2, caution: 3, none: 4 };
interface Target {
readonly mmsi: string;
readonly name: string;
readonly type: ShipType;
/** Closest point of approach, nautical miles; null when the target is not closing. */
readonly cpa: number | null;
/** Time to the closest point of approach, minutes; null when the target is not closing. */
readonly tcpa: number | null;
/** Speed over ground, knots. */
readonly sog: number;
readonly seen: Date;
readonly note: string;
}
/** The alert a target raises: alarm inside 0.5 NM within 12 min, warning inside 1 NM within 30 min. */
function priorityOf(target: Target): Priority {
if (target.cpa === null || target.tcpa === null) {
return "none";
}
if (target.cpa < 0.5 && target.tcpa < 12) {
return "alarm";
}
if (target.cpa < 1 && target.tcpa < 30) {
return "warning";
}
return target.cpa < 2 ? "caution" : "none";
}
const TARGETS: readonly Target[] = [
{
mmsi: "244123456",
name: "Rotterdam Express",
type: "Cargo",
cpa: 0.3,
tcpa: 8,
sog: 18.2,
seen: new Date("2026-10-11T14:02:10Z"),
note: "Crossing from port and the give-way vessel, with no change of course in six minutes. Call her on VHF 16.",
},
{
mmsi: "235987654",
name: "Saint-Malo Ferry",
type: "Passenger",
cpa: 0.9,
tcpa: 22,
sog: 21.5,
seen: new Date("2026-10-11T14:01:40Z"),
note: "Scheduled crossing; holds her course and speed.",
},
{
mmsi: "227112233",
name: "Ar Men",
type: "Fishing",
cpa: 1.4,
tcpa: 35,
sog: 3.1,
seen: new Date("2026-10-11T13:58:20Z"),
note: "Trawling with gear out about 300 m astern, restricted in her ability to maneuver.",
},
{
mmsi: "211445566",
name: "Elbe Spirit",
type: "Tanker",
cpa: 0.7,
tcpa: 17,
sog: 12,
seen: new Date("2026-10-11T14:00:50Z"),
note: "Laden, draft 12.4 m. Overtaking down our starboard side.",
},
{
mmsi: "244556677",
name: "Windgrazer",
type: "Sailing",
cpa: 2.8,
tcpa: 51,
sog: 6.4,
seen: new Date("2026-10-11T14:02:00Z"),
note: "Same passage, two miles ahead.",
},
{
mmsi: "244990011",
name: "Smit Tug 7",
type: "Tug",
cpa: 1.1,
tcpa: 28,
sog: 9.2,
seen: new Date("2026-10-11T14:01:10Z"),
note: "Towing a barge on a tow longer than 200 m. Pass astern of the barge, never between tug and tow.",
},
{
mmsi: "538009911",
name: "Pacific Trader",
type: "Cargo",
cpa: 4.2,
tcpa: 63,
sog: 14.8,
seen: new Date("2026-10-11T13:55:30Z"),
note: "",
},
{
mmsi: "316002244",
name: "Northern Light",
type: "Sailing",
cpa: null,
tcpa: null,
sog: 5.8,
seen: new Date("2026-10-11T13:51:00Z"),
note: "Opening; no closest approach ahead.",
},
{
mmsi: "228334455",
name: "Belle-Île Express",
type: "Passenger",
cpa: 3,
tcpa: 44,
sog: 16,
seen: new Date("2026-10-11T13:49:40Z"),
note: "Bound for Le Palais.",
},
];
/** A time of day as the bridge writes it: hours and minutes, with its reference. */
function utcTime(date: Date): string {
return `${String(date.getUTCHours()).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")} UTC`;
}
/** Each ship type as the AIS target row draws it. */
const AIS_SHIP_TYPES: Readonly<Record<ShipType, AisShipType>> = {
Cargo: "cargo",
Tanker: "tanker",
Passenger: "passenger",
Fishing: "fishing",
Sailing: "sailing",
Tug: "tug",
};
/** Each target's course over ground in degrees true, by MMSI, for the row of the target a press opens. */
const COURSES: Readonly<Record<string, number>> = {
"244123456": 95,
"235987654": 318,
"227112233": 240,
"211445566": 22,
"244556677": 210,
"244990011": 75,
"538009911": 12,
"316002244": 300,
"228334455": 182,
};
/** The list's time, 14:02:30 UTC, from which a target's report age is counted. */
const LIST_TIME: number = Date.UTC(2026, 9, 11, 14, 2, 30);
const ROWS: readonly Record<string, unknown>[] = TARGETS.map(
(target: Target): Record<string, unknown> => ({ ...target, priority: priorityOf(target) }),
);
const nameCell: CSSProperties = { display: "inline-flex", alignItems: "center", gap: "8px" };
const COLUMNS: readonly Column[] = [
{
key: "priority",
label: "Alert",
sortable: (a: Record<string, unknown>, b: Record<string, unknown>): number =>
PRIORITY_RANK[a.priority as Priority] - PRIORITY_RANK[b.priority as Priority],
filter: "option",
filterOptions: (["alarm", "warning", "caution", "none"] as const).map(
(priority: Priority): { value: string; label: string } => ({ value: priority, label: PRIORITY_WORDS[priority] }),
),
render: (row: Record<string, unknown>): ReactElement => {
const priority: Priority = row.priority as Priority;
return (
<Badge variant={priorityBadgeVariant[priority]}>{PRIORITY_WORDS[priority]}</Badge>
);
},
},
{
key: "name",
label: "Vessel",
sortable: true,
filter: "text",
render: (row: Record<string, unknown>): ReactElement => {
const Icon: LucideIcon = TYPE_ICONS[row.type as ShipType];
return (
<span style={nameCell}>
<Icon size={14} aria-hidden="true" />
{String(row.name)}
</span>
);
},
},
{ key: "type", label: "Type", sortable: true, filter: "option" },
{ key: "mmsi", label: "MMSI" },
{ key: "cpa", label: "CPA (NM)", align: "end", sortable: true },
{ key: "tcpa", label: "TCPA (min)", align: "end", sortable: true },
{ key: "sog", label: "SOG (kn)", align: "end", sortable: true },
{
key: "seen",
label: "Last seen",
sortable: true,
sortDescendingFirst: true,
render: (row: Record<string, unknown>): string => utcTime(row.seen as Date),
},
{ key: "note", label: "Note", wrap: true },
];
const SORTING: readonly ColumnSort[] = [
{ key: "priority", direction: "ascending" },
{ key: "cpa", direction: "ascending" },
];
const status: CSSProperties = {
display: "flex",
gap: "16px",
marginTop: "8px",
fontSize: "13px",
color: "var(--text-dim)",
};
const detail: CSSProperties = {
margin: "8px 0 0",
padding: 0,
border: "1px solid var(--border)",
borderRadius: "var(--radius-md)",
overflow: "hidden",
};
export default function Showcase(): ReactElement {
const [selected, setSelected]: [readonly string[], Dispatch<SetStateAction<readonly string[]>>] = useState<
readonly string[]
>(["244123456"]);
const [opened, setOpened]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
const openedTarget: Target | undefined = TARGETS.find((target: Target): boolean => target.mmsi === opened);
return (
<div style={{ width: "100%" }}>
<DataTable
columns={COLUMNS}
rows={ROWS}
rowKey={(row: Record<string, unknown>): string => String(row.mmsi)}
defaultSorting={SORTING}
globalFilterable
columnPicker
defaultHiddenColumns={["mmsi", "type", "sog", "seen"]}
pageSize={5}
selectable
selectedRowKeys={selected}
onSelectedRowKeysChange={setSelected}
onRowClick={(row: Record<string, unknown>): void => {
setOpened(String(row.mmsi));
}}
/>
<div style={status} aria-live="polite">
<span>{selected.length === 1 ? "1 target selected" : `${String(selected.length)} targets selected`}</span>
</div>
{openedTarget === undefined ? null : (
<ul style={detail} aria-label="Opened target">
<AisTargetRow
name={openedTarget.name}
mmsi={openedTarget.mmsi}
shipType={AIS_SHIP_TYPES[openedTarget.type]}
cpa={openedTarget.cpa}
tcpa={openedTarget.tcpa}
sog={openedTarget.sog}
cog={COURSES[openedTarget.mmsi] ?? null}
cpaLimit={0.5}
tcpaLimit={12}
ageSeconds={(LIST_TIME - openedTarget.seen.getTime()) / 1000}
/>
</ul>
)}
</div>
);
}
A data table shows records as rows under column headings: the ship's log, the alert history, the maintenance
tasks, the AIS targets in range. A cell shows its row's field for the column, or what the column's render draws
from the row, and stays empty when the field is missing. Everything else is opt-in, column by column or for the
whole table: sorting on a heading, a search field over every column, a filter under a heading, pages, a checkbox
per row, a Columns control, and a height past which the rows scroll under the heading row. Each piece of state the
crew change (the sort, the filters, the page, the selection, the hidden columns) is held by the table, or by the
caller through a value and its on…Change callback.
When to sort, filter and page
Sort a column whose order means something: a time, a distance, a count, a priority. Give a column that is read
newest or largest first sortDescendingFirst, so its first press shows what matters. A column whose values are
text in an order of their own, like alert priorities, takes a compare function. A missing value sorts last in both
directions, so a target with no CPA never rises above the closest one.
Filter a table the crew search more than they scan. The search field over every column suits the log and the
alert history, where the crew look for a word. A column filter suits a column with a few known values (priority,
system, vessel) as an option list, or a column of names or messages as text.
Page a table that grows without end, like the log or the alert history: the page holds a fixed number of rows,
and a change to the sort or a filter returns to the first page. Keep a live table that changes every few seconds,
like the AIS list, on one page with maxHeight when it is long, so a row does not jump to another page as its CPA
changes.
Examples
Passages
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";
export const description: string =
"Two passages in a table whose Name and Started headings sort the rows on a press; the second has no end date.";
export default function Passages(): ReactElement {
return (
<DataTable
columns={[
{ key: "name", label: "Name", sortable: true },
{ key: "vessel_name", label: "Vessel" },
{ key: "skipper_name", label: "Skipper" },
{ key: "started_at", label: "Started", sortable: true },
{ key: "ended_at", label: "Ended" },
]}
rows={[
{
name: "Biscay crossing",
vessel_name: "Althea",
skipper_name: "Mathias",
started_at: "2026-09-01",
ended_at: "2026-09-05",
},
{
name: "Solent day sail",
vessel_name: "Althea",
skipper_name: "Ana",
started_at: "2026-08-12",
ended_at: "",
},
]}
/>
);
}
Sortable columns
A press on a sortable heading sorts ascending, a second descending, a third returns the given order. Numbers and dates compare by value, text in the crew's language with numbers inside it by value.
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";
export const description: string =
"An engine log whose Time, Hours and Coolant headings sort on a press: times and numbers by value, newest time " +
"first, and the reading with no coolant temperature last in both directions.";
/** A time of day as the bridge writes it: hours and minutes, with its reference. */
function utcTime(date: Date): string {
return `${String(date.getUTCHours()).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")} UTC`;
}
export default function Sorting(): ReactElement {
return (
<DataTable
columns={[
{
key: "at",
label: "Time",
sortable: true,
sortDescendingFirst: true,
render: (row: Record<string, unknown>): string => utcTime(row.at as Date),
},
{ key: "hours", label: "Engine hours", align: "end", sortable: true },
{ key: "coolant", label: "Coolant (°C)", align: "end", sortable: true },
{ key: "note", label: "Note" },
]}
rows={[
{ at: new Date("2026-10-11T06:00:00Z"), hours: 1841.5, coolant: 82, note: "Start of watch" },
{ at: new Date("2026-10-11T10:00:00Z"), hours: 1845.5, coolant: 88, note: "Motoring in a calm" },
{ at: new Date("2026-10-11T08:00:00Z"), hours: 1843.5, coolant: null, note: "Sensor dropped out" },
{ at: new Date("2026-10-11T12:00:00Z"), hours: 1847.5, coolant: 79, note: "Sails up, engine off" },
]}
/>
);
}
Sort by several columns
defaultSorting starts the table sorted. Shift and a press adds a column to the sort or turns it where it stands;
the column header of the first sort key carries aria-sort, and each sorted heading describes its place.
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";
export const description: string =
"Maintenance tasks sorted by system and then by due date. A press on a heading sorts by that column alone; " +
"Shift and a press adds it to the sort, and the small number beside each arrow is its place.";
export default function MultiSort(): ReactElement {
return (
<DataTable
columns={[
{ key: "system", label: "System", sortable: true },
{ key: "task", label: "Task" },
{ key: "due", label: "Due", sortable: true },
{ key: "hours", label: "Due at (h)", align: "end", sortable: true },
]}
defaultSorting={[
{ key: "system", direction: "ascending" },
{ key: "due", direction: "ascending" },
]}
rows={[
{ system: "Engine", task: "Change the oil and filter", due: "2026-10-20", hours: 1900 },
{ system: "Rigging", task: "Check the forestay toggle", due: "2026-11-02", hours: null },
{ system: "Engine", task: "Replace the raw-water impeller", due: "2026-10-14", hours: 1850 },
{ system: "Safety", task: "Service the life raft", due: "2027-03-01", hours: null },
{ system: "Rigging", task: "Lubricate the furler", due: "2026-10-18", hours: null },
{ system: "Safety", task: "Replace the EPIRB battery", due: "2026-12-15", hours: null },
]}
/>
);
}
Search over every column
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";
export const description: string =
"The ship's log with a search field above it: typing keeps the entries whose text in any column contains what " +
"was typed, ignoring case. Try reef or Ana.";
export default function GlobalFilter(): ReactElement {
return (
<DataTable
globalFilterable
columns={[
{ key: "time", label: "Time" },
{ key: "author", label: "By" },
{ key: "entry", label: "Entry", wrap: true },
]}
rows={[
{ time: "06:00 UTC", author: "Mathias", entry: "Took the watch. Wind SW 14 kn, sea moderate, barometer 1012 hPa." },
{ time: "07:30 UTC", author: "Ana", entry: "Put in the first reef, gusts to 24 kn." },
{ time: "09:10 UTC", author: "Ana", entry: "Ushant lighthouse abeam to port, 9 NM." },
{ time: "11:45 UTC", author: "Mathias", entry: "Shook out the reef; wind easing to 12 kn." },
{ time: "13:20 UTC", author: "Joe", entry: "Fishing vessel crossed ahead, passed clear astern of her." },
]}
/>
);
}
Column filters
An option filter lists the column's distinct values in order, or the filterOptions the column gives with their
labels; a text filter keeps the cells that contain the typed text.
Code
import { Badge } from "@bosunkit/components/badge";
import { DataTable } from "@bosunkit/components/data-table";
import { priorityBadgeVariant, type Priority } from "@bosunkit/components/priority";
import type { ReactElement } from "react";
export const description: string =
"The alert history with a filter under three headings: a list of priorities by name, a list of the sources " +
"found in the rows, and a search field over the message. Every filter must keep a row for it to show.";
const PRIORITY_WORDS: Readonly<Record<Priority, string>> = {
emergency: "Emergency",
alarm: "Alarm",
warning: "Warning",
caution: "Caution",
none: "No alert",
};
export default function ColumnFilters(): ReactElement {
return (
<DataTable
columns={[
{
key: "priority",
label: "Priority",
filter: "option",
filterOptions: (["alarm", "warning", "caution"] as const).map(
(priority: Priority): { value: string; label: string } => ({
value: priority,
label: PRIORITY_WORDS[priority],
}),
),
render: (row: Record<string, unknown>): ReactElement => {
const priority: Priority = row.priority as Priority;
return <Badge variant={priorityBadgeVariant[priority]}>{PRIORITY_WORDS[priority]}</Badge>;
},
},
{ key: "source", label: "Source", filter: "option" },
{ key: "message", label: "Message", filter: "text", wrap: true },
{ key: "time", label: "Raised" },
]}
rows={[
{ priority: "alarm", source: "Anchor watch", message: "Anchor dragging, 38 m outside the swing circle", time: "02:14 UTC" },
{ priority: "warning", source: "Bilge", message: "Aft bilge pump running for 4 min", time: "03:40 UTC" },
{ priority: "caution", source: "Battery", message: "House bank at 12.1 V", time: "05:05 UTC" },
{ priority: "warning", source: "Engine", message: "Coolant at 96 °C and rising", time: "09:12 UTC" },
{ priority: "caution", source: "Depth", message: "Depth 6.2 m, under the 7 m shallow limit", time: "11:30 UTC" },
{ priority: "alarm", source: "AIS", message: "Rotterdam Express, CPA 0.3 NM in 8 min", time: "14:02 UTC" },
]}
/>
);
}
No rows match
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";
export const description: string =
"A passage list filtered to nothing: the table names the text and the column filter that hid every row, and " +
"Clear filters brings the rows back.";
export default function NoMatch(): ReactElement {
return (
<DataTable
globalFilterable
defaultGlobalFilter="Lisbon"
defaultColumnFilters={[{ key: "vessel", value: "Althea" }]}
columns={[
{ key: "name", label: "Passage" },
{ key: "vessel", label: "Vessel", filter: "option" },
{ key: "started", label: "Started" },
]}
rows={[
{ name: "Biscay crossing", vessel: "Althea", started: "2026-09-01" },
{ name: "Solent day sail", vessel: "Althea", started: "2026-08-12" },
{ name: "Falmouth to Porto", vessel: "Windgrazer", started: "2026-07-20" },
]}
/>
);
}
No rows
empty is what the table shows with no rows at all, whatever the filters. It says what the empty table means for
this table and what to check.
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";
export const description: string =
"An AIS target list with no targets: the table shows the caller's text, which says what the empty list means " +
"and what to check, apart from the no-match state a filter gives.";
export default function NoRows(): ReactElement {
return (
<DataTable
globalFilterable
columns={[
{ key: "name", label: "Vessel" },
{ key: "cpa", label: "CPA (NM)", align: "end" },
{ key: "tcpa", label: "TCPA (min)", align: "end" },
]}
rows={[]}
empty="No AIS targets in range. If a vessel is in sight, check that the AIS receiver is on and has power."
/>
);
}
Pages
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";
export const description: string =
"A day of hourly log entries, five to a page, with the range and the page buttons under the table. Sorting or " +
"filtering goes back to the first page.";
const WINDS: readonly string[] = ["SW 12 kn", "SW 14 kn", "WSW 16 kn", "W 18 kn", "W 15 kn", "WNW 11 kn"];
const ROWS: readonly Record<string, unknown>[] = Array.from(
{ length: 23 },
(_value: unknown, hour: number): Record<string, unknown> => ({
time: `${String(hour).padStart(2, "0")}:00 UTC`,
log: 1240 + hour * 6,
wind: WINDS[hour % WINDS.length],
watch: hour % 8 < 4 ? "Mathias" : "Ana",
}),
);
export default function Pages(): ReactElement {
return (
<DataTable
pageSize={5}
columns={[
{ key: "time", label: "Time", sortable: true },
{ key: "log", label: "Log (NM)", align: "end", sortable: true },
{ key: "wind", label: "Wind" },
{ key: "watch", label: "On watch", filter: "option" },
]}
rows={ROWS}
/>
);
}
Selection
rowKey keeps a row's selection with the row through a sort and a new set of rows. The select-all box selects every
row the filters keep, on every page.
Code
import { Button } from "@bosunkit/components/button";
import { DataTable } from "@bosunkit/components/data-table";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Maintenance tasks with a checkbox on each row and one that selects every task the filter keeps. The page holds " +
"the selection and offers an action for the tasks selected.";
const bar: CSSProperties = { display: "flex", alignItems: "center", gap: "12px", marginTop: "8px" };
export default function Selection(): ReactElement {
const [selected, setSelected]: [readonly string[], Dispatch<SetStateAction<readonly string[]>>] = useState<
readonly string[]
>(["impeller"]);
return (
<div style={{ width: "100%" }}>
<DataTable
selectable
rowKey={(row: Record<string, unknown>): string => String(row.id)}
selectedRowKeys={selected}
onSelectedRowKeysChange={setSelected}
columns={[
{ key: "task", label: "Task" },
{ key: "system", label: "System", filter: "option" },
{ key: "due", label: "Due", sortable: true },
]}
rows={[
{ id: "oil", task: "Change the oil and filter", system: "Engine", due: "2026-10-20" },
{ id: "impeller", task: "Replace the raw-water impeller", system: "Engine", due: "2026-10-14" },
{ id: "furler", task: "Lubricate the furler", system: "Rigging", due: "2026-10-18" },
{ id: "raft", task: "Service the life raft", system: "Safety", due: "2027-03-01" },
]}
/>
<div style={bar}>
<Button
size="sm"
disabled={selected.length === 0}
onClick={(): void => {
setSelected([]);
}}
>
{selected.length === 1 ? "Mark 1 task done" : `Mark ${String(selected.length)} tasks done`}
</Button>
</div>
</div>
);
}
Columns
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";
export const description: string =
"An AIS target list with a Columns control that lists every column; MMSI and course start hidden, and the last " +
"column shown cannot be hidden.";
export default function Columns(): ReactElement {
return (
<DataTable
columnPicker
defaultHiddenColumns={["mmsi", "cog"]}
columns={[
{ key: "name", label: "Vessel" },
{ key: "mmsi", label: "MMSI" },
{ key: "cpa", label: "CPA (NM)", align: "end", sortable: true },
{ key: "tcpa", label: "TCPA (min)", align: "end", sortable: true },
{ key: "sog", label: "SOG (kn)", align: "end" },
{ key: "cog", label: "COG (°)", align: "end" },
]}
rows={[
{ name: "Rotterdam Express", mmsi: "244123456", cpa: 0.3, tcpa: 8, sog: 18.2, cog: "074" },
{ name: "Saint-Malo Ferry", mmsi: "235987654", cpa: 0.9, tcpa: 22, sog: 21.5, cog: "152" },
{ name: "Ar Men", mmsi: "227112233", cpa: 1.4, tcpa: 35, sog: 3.1, cog: "301" },
]}
/>
);
}
Scrolling under the heading row
Code
import { DataTable } from "@bosunkit/components/data-table";
import type { ReactElement } from "react";
export const description: string =
"Twenty soundings in a table capped at 15rem: the rows scroll inside it and the heading row stays at the top.";
const ROWS: readonly Record<string, unknown>[] = Array.from(
{ length: 20 },
(_value: unknown, index: number): Record<string, unknown> => ({
time: `14:${String(index * 3).padStart(2, "0")} UTC`,
depth: [12, 11, 10, 9.7, 8.8][index % 5],
position: `48°${String(27 + index)}.4'N 004°${String(51 - index)}.2'W`,
}),
);
export default function Scrolling(): ReactElement {
return (
<DataTable
maxHeight="15rem"
columns={[
{ key: "time", label: "Time" },
{ key: "depth", label: "Depth (m)", align: "end", sortable: true },
{ key: "position", label: "Position" },
]}
rows={ROWS}
/>
);
}
Cell layout
Each column takes the width its heading and values need, so a CPA or a time stays narrow and whole. A column
marked wrap takes the width left over and breaks its text onto more lines; a table wider than its frame scrolls
sideways. Every cell starts at the top of its row, with the checkbox on the first line.
Code
import { DataTable } from "@bosunkit/components/data-table";
import { Anchor, Fuel, Wrench, type LucideIcon } from "lucide-react";
import type { CSSProperties, ReactElement } from "react";
export const description: string =
"Cells drawn from their rows: an icon beside each category, amounts aligned at the end so their digits line up, " +
"and a note column whose text wraps onto as many lines as it needs.";
const ICONS: Readonly<Record<string, LucideIcon>> = { Fuel: Fuel, Berth: Anchor, Repair: Wrench };
const category: CSSProperties = { display: "inline-flex", alignItems: "center", gap: "8px" };
export default function CellLayout(): ReactElement {
return (
<DataTable
columns={[
{
key: "category",
label: "Category",
render: (row: Record<string, unknown>): ReactElement => {
const Icon: LucideIcon = ICONS[String(row.category)] ?? Wrench;
return (
<span style={category}>
<Icon size={14} aria-hidden="true" />
{String(row.category)}
</span>
);
},
},
{ key: "amount", label: "Amount (EUR)", align: "end", sortable: true },
{ key: "note", label: "Note", wrap: true },
]}
rows={[
{ category: "Fuel", amount: 182.4, note: "Diesel at Valletta, 98 L; the pump stopped twice at the same reading." },
{ category: "Berth", amount: 64, note: "One night in Marina di Ragusa." },
{
category: "Repair",
amount: 1240,
note: "New raw-water pump fitted in Licata after the old one wept from the shaft seal. The yard kept the old pump.",
},
]}
/>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| columnsrequired | readonly Column[] | – | The columns, in order. |
| rowsrequired | readonly Record<string, unknown>[] | – | The rows; a cell shows its column's field, empty when the field is missing. |
| onRowClick | ((row: Record<string, unknown>) => void) | undefined | – | Called with a row when the crew press it, or press Enter or Space on it. Rows are not pressable when unset. |
| sorting | readonly ColumnSort[] | undefined | – | The columns the rows are sorted by, first column first, when the caller holds the sort; the table then shows
this sort and reports a press through onSortingChange. A column that is not sortable is left out. |
| defaultSorting | readonly ColumnSort[] | undefined | NO_SORTING | The sort the table starts with when it holds the sort itself. |
| onSortingChange | ((sorting: readonly ColumnSort[]) => void) | undefined | – | Called with the new sort when the crew press a heading: Shift adds the column to the sort or turns it. |
| globalFilterable | boolean | undefined | false | Puts a field above the table that keeps the rows whose text in any column contains the typed text. |
| searchRef | Ref<HTMLInputElement> | undefined | – | The field above the table, for a caller that focuses it, such as on a key the page gives it. |
| searchKeyShortcuts | string | undefined | – | The keys that focus the field above the table, as aria-keyshortcuts names them ("/"); the caller handles them. |
| globalFilter | string | undefined | – | The text of the field above the table, when the caller holds it; pair it with onGlobalFilterChange. |
| defaultGlobalFilter | string | undefined | "" | The text the field above the table starts with when the table holds it itself. |
| onGlobalFilterChange | ((value: string) => void) | undefined | – | Called with the text of the field above the table as the crew type. |
| columnFilters | readonly ColumnFilter[] | undefined | – | The column filters, when the caller holds them; pair it with onColumnFiltersChange. |
| defaultColumnFilters | readonly ColumnFilter[] | undefined | NO_FILTERS | The column filters the table starts with when it holds them itself. |
| onColumnFiltersChange | ((filters: readonly ColumnFilter[]) => void) | undefined | – | Called with every column filter that is set when the crew change one; a cleared filter is left out. |
| empty | ReactNode | – | What the table shows when it has no rows at all; "No rows to show." when unset. |
| pageSize | number | undefined | – | Shows the rows that many at a time, with controls under the table to move between pages; every row at once when unset. A change to the sort or a filter goes back to the first page. |
| pageIndex | number | undefined | – | The page shown, from 0, when the caller holds it; pair it with onPageIndexChange. |
| defaultPageIndex | number | undefined | 0 | The page the table starts on when it holds the page itself. |
| onPageIndexChange | ((pageIndex: number) => void) | undefined | – | Called with the page to show when the crew move between pages, or when a change sends the table to page 0. |
| columnPicker | boolean | undefined | false | Puts a Columns control above the table that lists every column with a checkbox, so the crew can hide the columns they do not need; the last column shown cannot be hidden. A hidden column still sorts and filters the rows. |
| hiddenColumns | readonly string[] | undefined | – | The keys of the hidden columns, when the caller holds them; pair it with onHiddenColumnsChange. |
| defaultHiddenColumns | readonly string[] | undefined | NO_KEYS | The keys of the columns hidden at first when the table holds them itself. |
| onHiddenColumnsChange | ((keys: readonly string[]) => void) | undefined | – | Called with the keys of every hidden column when the crew tick or clear a column. |
| maxHeight | string | undefined | – | The table's greatest height, as CSS; past it the rows scroll under the heading row, which stays in view. The table grows with its rows when unset. A table wider than its frame scrolls sideways in the same box. |
| selectable | boolean | undefined | false | Puts a checkbox before each row and one in the heading row that selects every row the filters keep. |
| rowKey | ((row: Record<string, unknown>) => string) | undefined | – | A row's identity for the selection and for drawing, which stays with the row through a sort or a new set of rows;
the row's place in rows when unset, which suits rows that do not change while they are shown. |
| selectedRowKeys | readonly string[] | undefined | – | The keys of the selected rows, when the caller holds them; pair it with onSelectedRowKeysChange. |
| defaultSelectedRowKeys | readonly string[] | undefined | NO_KEYS | The keys of the rows selected at first when the table holds the selection itself. |
| onSelectedRowKeysChange | ((keys: readonly string[]) => void) | undefined | – | Called with the keys of every selected row when the crew tick or clear a box. A selected row a filter hides stays selected. |
| className | string | undefined | – | Classes added to the outermost element: the table, or the frame that holds it with its filter field or pages. |
Related
- Entity list: a table with an Add button and the form that edits a row.
- Badge: the priority label a cell draws for an alert or a target.
- Accessibility: the target sizes and the keyboard path the controls follow.
A live list, such as the AIS targets, holds its order while the crew are in the table: with focus in it or the pointer over it, an update that would re-sort the rows leaves them where they are, a new row comes in at the end, and the order applies when they leave. A sort or a filter the crew set applies at once.