a table to read and edit: thousands of rows, sticky heads, a spreadsheet's keys
npx shadcn add @ag/tableAlready installed? Preview an update
npx shadcn add @ag/table --dry-runYour installed source stays unchanged until you update it. How updates work
- Changed: the choices under a cell being edited (
data-slot="table-choices", a text column's few repeated values) are a frame that stays whole, and the options scroll in an innerrole="listbox"element that fades its cut edges. The frame keeps the slot; the listbox role, id and label moved to the inner element.
also called data grid
shelf data & status
uses scrims, selection, key-router, menu, scroll-area, scroll-thumb, shortcut
the demo above, verbatim
"use client"
import "@/registry/base-nova/ui/edge-fade"
import { ArrowUp, Pause, Play, Trash2 } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import type { MenuEntry } from "@/registry/base-nova/ui/menu"
import {
CsvTable,
Table,
type TableColumn,
type TableDensity,
} from "@/registry/base-nova/ui/table"
// #region csv
// A file's text in, the text out on every edit: the table is the editor, the CSV
// stays the truth. Kinds are read: cost is a number, paid a yes/no, on a date.
const BUDGET = `item,category,cost,paid,on
fuel,travel,84.20,yes,2026-08-14
tolls,travel,23.50,yes,2026-08-14
cabin,stay,310.00,yes,2026-08-15
groceries,food,96.75,no,2026-08-15
ferry,travel,58.00,no,2026-08-17
sd cards,gear,42.99,yes,2026-08-12
`
// #endregion
function Csv() {
const [text, setText] = React.useState(BUDGET)
return (
<div className="grid w-full gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">
<CsvTable
label="budget.csv"
text={BUDGET}
onChange={setText}
className="h-80 rounded-lg border border-foreground/8"
/>
{/* The surface a frame round the scroller, whose cut edges fade. */}
<div className="flex h-80 rounded-lg bg-foreground/4">
<pre className="ag-edge-fade-xy min-h-0 flex-1 p-3 font-mono text-[11px] leading-5 text-foreground/70">
{text}
</pre>
</div>
</div>
)
}
// #region rows
// Rows of your own: the text sorts and copies, `render` draws what text cannot, a
// tint marks priority, and the verbs read what they act on.
interface Download {
id: string
title: string
state: "downloading" | "paused" | "queued"
progress: number
speed: number
size: number
priority: "high" | "normal" | "low"
}
const DOWNLOADS: Download[] = [
{
id: "d1",
title: "the bay, wide",
state: "downloading",
progress: 0.62,
speed: 41.2,
size: 4.1,
priority: "high",
},
{
id: "d2",
title: "hands on the map",
state: "downloading",
progress: 0.18,
speed: 12.5,
size: 1.6,
priority: "normal",
},
{
id: "d3",
title: "light through the trees",
state: "paused",
progress: 0.4,
speed: 0,
size: 2.3,
priority: "normal",
},
{
id: "d4",
title: "the boat leaving",
state: "queued",
progress: 0,
speed: 0,
size: 3.8,
priority: "low",
},
{
id: "d5",
title: "the pass at dusk",
state: "queued",
progress: 0,
speed: 0,
size: 5.2,
priority: "high",
},
]
const COLUMNS: TableColumn<Download>[] = [
{ id: "title", label: "title", text: (d) => d.title },
// A few values repeated: read as choices, drawn as badges.
{ id: "state", label: "state", text: (d) => d.state },
{
id: "progress",
label: "progress",
text: (d) => `${Math.round(d.progress * 100)}%`,
width: "9rem",
render: (d) => (
<span className="flex w-full items-center gap-2">
<span className="h-1 flex-1 overflow-hidden rounded-full bg-foreground/10">
<span
className="block h-full w-(--done) rounded-full bg-foreground/60"
style={{ "--done": `${d.progress * 100}%` } as React.CSSProperties}
/>
</span>
<span className="w-8 text-right text-[11px] text-foreground/50 tabular-nums">
{Math.round(d.progress * 100)}%
</span>
</span>
),
},
{
id: "speed",
label: "MB/s",
// Unknowable while not downloading: an empty cell, never a fake zero.
text: (d) => (d.state === "downloading" ? d.speed.toFixed(1) : ""),
},
{ id: "size", label: "GB", text: (d) => d.size.toFixed(1) },
]
// #endregion
function Rows() {
const [rows, setRows] = React.useState(DOWNLOADS)
const patch = (ids: readonly string[], f: (d: Download) => Download) =>
setRows((all) => all.map((d) => (ids.includes(d.id) ? f(d) : d)))
const actions = (ids: readonly string[]): MenuEntry[] => {
const chosen = rows.filter((d) => ids.includes(d.id))
const allPaused = chosen.every((d) => d.state === "paused")
return [
allPaused
? {
icon: <Play />,
label: "resume",
onSelect: () => patch(ids, (d) => ({ ...d, state: "downloading" })),
}
: {
icon: <Pause />,
label: "pause",
onSelect: () => patch(ids, (d) => ({ ...d, state: "paused" })),
},
{
icon: <ArrowUp />,
label: "high priority",
disabled: chosen.every((d) => d.priority === "high"),
onSelect: () => patch(ids, (d) => ({ ...d, priority: "high" })),
},
"separator",
{
icon: <Trash2 />,
label: "remove",
destructive: true,
onSelect: () =>
setRows((all) => all.filter((d) => !ids.includes(d.id))),
},
]
}
return (
<Table
label="downloads"
rows={rows}
rowId={(d) => d.id}
columns={COLUMNS}
actions={actions}
noun="download"
tint={(d) =>
d.priority === "high"
? "color-mix(in oklab, var(--color-foreground) 5%, transparent)"
: undefined
}
className="h-52 w-full rounded-lg border border-foreground/8"
/>
)
}
// #region saved
// Every edit is saved somewhere slow and fallible: `onEdit` returns the save's
// promise, so the cells show the new text at once and roll back if it is refused.
// Status is a choice (read from its values), active a switch.
interface Account {
id: string
name: string
status: string
plan: string
active: string
}
const ACCOUNTS: Account[] = [
{
id: "a1",
name: "north studio",
status: "live",
plan: "team",
active: "yes",
},
{
id: "a2",
name: "harbour films",
status: "trial",
plan: "solo",
active: "yes",
},
{ id: "a3", name: "pass & co", status: "paused", plan: "team", active: "no" },
{
id: "a4",
name: "ridge sound",
status: "live",
plan: "solo",
active: "yes",
},
{
id: "a5",
name: "market lane",
status: "live",
plan: "team",
active: "yes",
},
{ id: "a6", name: "cabin edit", status: "trial", plan: "solo", active: "no" },
]
const ACCOUNT_COLUMNS: TableColumn<Account>[] = [
{ id: "name", label: "name", text: (a) => a.name },
{ id: "status", label: "status", text: (a) => a.status },
// Declared: a plan the data has not used yet is still offered.
{
id: "plan",
label: "plan",
text: (a) => a.plan,
options: ["solo", "team", "studio"],
},
{ id: "active", label: "active", text: (a) => a.active },
]
/** A server: answers after a moment, and refuses when told to. */
const save = (refuse: boolean) =>
new Promise<void>((resolve, reject) =>
setTimeout(
() => (refuse ? reject(new Error("the server said no")) : resolve()),
900,
),
)
function Saved() {
const [rows, setRows] = React.useState(ACCOUNTS)
const [refuse, setRefuse] = React.useState(false)
return (
<div className="w-full space-y-3">
<label className="flex w-fit items-center gap-2 text-[13px] text-foreground/70">
<input
type="checkbox"
checked={refuse}
onChange={(e) => setRefuse(e.target.checked)}
/>
the server refuses
</label>
<Table
label="accounts"
rows={rows}
rowId={(a) => a.id}
columns={ACCOUNT_COLUMNS}
noun="account"
onEdit={async (edits) => {
await save(refuse)
setRows((all) =>
all.map((a) => ({
...a,
...Object.fromEntries(
edits
.filter((e) => e.row === a.id)
.map((e) => [e.column, e.text]),
),
})),
)
}}
className="h-64 w-full rounded-lg border border-foreground/8"
/>
</div>
)
}
// #endregion
// #region staged
// A config whose edits wait: change what you like, see what diverges, then apply it
// as one batch (or discard it).
const SETTINGS = [
{ id: "s1", key: "workers", value: "4", env: "production" },
{ id: "s2", key: "timeout", value: "30s", env: "production" },
{ id: "s3", key: "retries", value: "3", env: "production" },
{ id: "s4", key: "workers", value: "1", env: "staging" },
{ id: "s5", key: "timeout", value: "120s", env: "staging" },
{ id: "s6", key: "retries", value: "0", env: "staging" },
]
type Setting = (typeof SETTINGS)[number]
const SETTING_COLUMNS: TableColumn<Setting>[] = [
// Repeats, but names a thing rather than a state: free text, not choices.
{
id: "key",
label: "key",
text: (s) => s.key,
editable: false,
options: [],
},
{ id: "env", label: "env", text: (s) => s.env, editable: false },
{ id: "value", label: "value", text: (s) => s.value },
]
function Staged() {
const [rows, setRows] = React.useState(SETTINGS)
return (
<Table
label="settings"
rows={rows}
rowId={(s) => s.id}
columns={SETTING_COLUMNS}
staged
onEdit={async (edits) => {
await save(false)
setRows((all) =>
all.map((s) => {
const e = edits.find((e) => e.row === s.id)
return e ? { ...s, value: e.text } : s
}),
)
}}
className="h-64 w-full rounded-lg border border-foreground/8"
/>
)
}
// #endregion
const DENSITIES: TableDensity[] = ["compact", "comfortable", "spacious"]
function Many() {
const [density, setDensity] = React.useState<TableDensity>("compact")
return (
<div className="w-full space-y-3">
<fieldset className="flex w-fit gap-0.5 rounded-lg bg-foreground/5 p-0.5 text-[12px]">
<legend className="sr-only">density</legend>
{DENSITIES.map((d) => (
<label
key={d}
className="cursor-pointer rounded-md px-2.5 py-1 text-foreground/55 has-checked:bg-background has-checked:text-foreground has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-ring"
>
<input
type="radio"
name="density"
value={d}
checked={density === d}
onChange={() => setDensity(d)}
className="sr-only"
/>
{d}
</label>
))}
</fieldset>
<Table
label="shots"
rows={MANY}
rowId={(s) => s.id}
columns={MANY_COLUMNS}
density={density}
className="h-96 w-full rounded-lg border border-foreground/8"
/>
</div>
)
}
// #region many
// Ten thousand rows cost what the view shows: only those are mounted.
const PLACES = ["bay", "pass", "harbour", "ridge", "market", "ferry", "cabin"]
const MANY = Array.from({ length: 10_000 }, (_, i) => ({
id: `m${i}`,
shot: `${PLACES[i % PLACES.length]} ${Math.floor(i / PLACES.length) + 1}`,
seconds: ((i * 7919) % 600) / 10,
kept: (i * 31) % 3 === 0 ? "yes" : "no",
on: `2026-${String((i % 12) + 1).padStart(2, "0")}-${String((i % 28) + 1).padStart(2, "0")}`,
}))
type Shot = (typeof MANY)[number]
const MANY_COLUMNS: TableColumn<Shot>[] = [
{ id: "shot", label: "shot", text: (s) => s.shot },
{ id: "seconds", label: "seconds", text: (s) => s.seconds.toFixed(1) },
{ id: "kept", label: "kept", text: (s) => s.kept },
{ id: "on", label: "on", text: (s) => s.on },
]
// #endregion
export default function TableDemo() {
return (
<div className="space-y-8">
<Sample
name="csv"
title="a CSV, edited in place"
description="Double-click, Enter or type to edit; Enter goes down, Tab right, Esc gives up. A check flips on a press, Space or Enter. ⇧ with a move or a click, or a drag, takes a block of cells: ⌘C copies it, ⌘V pastes into it (one value fills it), Backspace clears it, ⌘D and ⌘R fill it down and right. ⌘ with an arrow jumps to the edge of the data, ⌘G goes to an address (B3, 4, C, A2:B5), ⌘Z undoes. A header sorts, its edge resizes, its right click inserts, moves or deletes columns, a double-click renames it. Right-click rows to insert, duplicate or delete. The text on the right is what would be saved."
with="csv"
>
<Csv />
</Sample>
<Sample
name="rows"
glance="wide"
title="rows of your own"
description="Click a row's number to select it, ⌘ adds, ⇧ a range; from the keyboard ⇧Space takes the rows of the cells you have, Space the cursor's. The verbs rise in a bar, the same ones a right click offers."
with="rows"
>
<Rows />
</Sample>
<Sample
name="saved"
title="saved, and maybe refused"
description="Each edit shows at once, faded until the save answers. Tick the box and edit again: the save is refused, the cells go back and the reason shows at the foot. Status and plan are choices (Enter or type to pick, or write a new one), active flips on Space."
with="saved"
>
<Saved />
</Sample>
<Sample
name="staged"
title="staged, applied as one"
description="Edit values: each changed cell is marked and remembers what it was (hover it), ⌘Z steps back, and the bar applies them all in one call (⌘S) or discards them."
with="staged"
>
<Staged />
</Sample>
<Sample name="many" title="ten thousand rows, any density" with="many">
<Many />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// A TABLE, to read and to edit: thousands of rows windowed, the header and the first
// column held while the rest scrolls under them, columns sorted and resized, a cell
// cursor and a block of cells walked by the keyboard as a spreadsheet's, cells edited
// in place, rows selected the one way every set is (selection), and the rows' verbs on
// a right click and on a bar.
//
// THE CONSUMER OWNS THE DATA. A column says how to read a row's text (what sorts, what
// its kind is read from, what an edit starts from, what is copied) and, when the text
// alone is not the face, how to draw it (`render`: a badge, a progress bar). Edits are
// handed back as text, every cell one gesture changed in one call (`onEdit`), so a
// paste or a fill is one change and one undo; the table never writes. `CsvTable` below
// is one consumer: a CSV's text in, the text written back out on every change.
//
// KINDS ARE READ (table-data): numbers right-aligned in tabular figures, dates in
// tabular figures, a yes/no column as a check that a press flips (writing the column's
// own words back), everything else text. Sorting follows the kind; empty cells last. A
// text column that repeats a few values chooses among them (`choicesOf`, or the
// column's `options`): its cells wear the value as a badge and an edit offers the
// values as a list that narrows as you type, a new value still written as typed.
//
// AN EDIT IS ASKED FOR, AND MAY BE REFUSED. When `onEdit` answers with a promise the
// cells show the new text at once, dimmed, until it settles: kept when it resolves (the
// consumer's rows hold it by then), rolled back when it rejects, the cells marked and
// the reason said at the foot. With `staged`, edits are not asked for at all until
// applied: each changed cell carries a mark and what it was, ⌘Z steps back through
// them, and a bar at the foot applies them as one call (⌘S) or discards them. What is
// shown, sorted, found and copied is always the text as it will be.
//
// ONE GRID. Every row, the header included, is a CSS grid on one template, so a column
// is one width everywhere and a resize moves it everywhere at once. The gutter (row
// numbers, the rows' own selection handle) and the first column are sticky to the
// left, the header to the top. The table sits on one ground (`--ag-table-ground`, the
// page colour unless set) and the sticky parts are that ground, carrying their row's
// tint as a gradient, so nothing reads as a band of another colour.
//
// TWO SELECTIONS, a spreadsheet's. CELLS: the cursor, and a block from an anchor to
// it. A click puts the cursor on a cell; ⇧-click, ⇧ with any move, or a drag across
// cells grows the block; copy, paste, clear and fill act on it. ROWS (selection, the
// kernel the gallery and the rail share): a click on a row's number selects it alone,
// ⌘ adds, ⇧ a range in the shown order; ⇧Space takes the block's rows, Space the
// cursor's, ⌘A all; the rows' verbs act on them. A plain move or click leaves both.
//
// THE KEYS. Arrows, Tab, Home/End (row start and end; ⌘ the first and last cell),
// PageUp/Down, ⌘ with an arrow to the edge of the data (Excel's ⌃-arrow), all of them
// growing the block with ⇧. Enter, F2, a double-click or typing edits (Enter commits and
// goes down, Tab right, Esc gives up); Space or Enter flips a check. Backspace clears
// the block, ⌘D fills it down and ⌘R right from its first row or column, ⌘C copies it
// (or the selected rows) as tab-separated text and ⌘V pastes a block of text at it (one
// value fills it), ⌘Z and ⇧⌘Z undo and redo (staged edits first, then the consumer's
// history when it keeps one), ⌘S applies staged edits. ⌘G goes
// to an address: the corner box shows the cursor's ("C3", the block's "B2:D5") and
// takes one typed, a cell, a row ("9"), a column ("B") or a block, as sheets' `:B9`.
// ⌘F finds: a bar over the top right corner, every cell holding the words washed, the
// cursor on the first at once, Enter and ⇧Enter walking them, Esc closing. Esc lets go
// of the block, then the rows. A header click sorts (ascending, descending, as given);
// a header dragged sideways moves its column (a line shows where it lands); its edge
// resizes the column (a double-click there fits it); its right click opens the
// column's verbs. The grid is one tab stop: the cursor is `aria-activedescendant`, and
// its row is always mounted.
import {
ArrowDown,
ArrowDownWideNarrow,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowUpNarrowWide,
Check,
Copy,
Plus,
Rows3,
Trash2,
Undo2,
X,
} from "lucide-react"
import * as React from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import {
actsOn,
all,
inOrder,
NONE,
only,
range,
type Selection,
toggle,
} from "@/registry/base-nova/lib/selection"
import {
type ColumnKind,
type Csv,
choicesOf,
columnName,
flipped,
kindOf,
parseAddress,
parseCsv,
type SortDirection,
sorted,
toCsv,
truthOf,
truthyOf,
} from "@/registry/base-nova/lib/table-data"
import { ownedHere, typingIn } from "@/registry/base-nova/ui/key-router"
import { Menu, type MenuEntry, verbFor } from "@/registry/base-nova/ui/menu"
import { ScrollArea } from "@/registry/base-nova/ui/scroll-area"
import {
NO_SCROLLBAR,
ScrollThumbs,
} from "@/registry/base-nova/ui/scroll-thumb"
import {
BAR,
BAR_RULE,
BarVerb,
SelectionBar,
} from "@/registry/base-nova/ui/selection-bar"
import { usePlatform } from "@/registry/base-nova/ui/shortcut"
export interface TableColumn<R> {
id: string
label: string
/** The cell's text: what sorts, what the column's kind is read from, what an edit
* starts from and what is copied. */
text: (row: R) => string
/** The cell's face when its text is not enough. */
render?: (row: R) => React.ReactNode
/** Read from the column's texts when not given. */
kind?: ColumnKind
/** A CSS length; the content's width in characters when not given. */
width?: string
/** Defaults to whether the table takes edits. */
editable?: boolean
/** The values a cell may take (a status, a category): drawn as a badge, offered as
* a list when edited, a typed value still written as typed. Read from the column
* when its texts repeat a few values; `[]` reads it as free text. */
options?: readonly string[]
}
/** One cell's new text. */
export interface CellEdit {
row: string
column: string
text: string
}
export type TableDensity = "compact" | "comfortable" | "spacious"
export interface TableProps<R> {
rows: readonly R[]
rowId: (row: R) => string
columns: readonly TableColumn<R>[]
/** The grid's accessible name. */
label: string
/** The cells one gesture changed (an edit, a paste, a fill, a clear), committed
* together. The consumer applies them; the table only asks. A promise makes the
* edit optimistic: the new text shows until it settles, and a rejection rolls the
* cells back and says why (its message). */
// biome-ignore lint/suspicious/noConfusingVoidType: a callback that applies at once returns nothing (a setState), one that saves returns its promise
onEdit?: (edits: readonly CellEdit[]) => void | Promise<unknown>
/** Edits wait instead of being asked for: marked on their cells, undone with ⌘Z,
* and handed to `onEdit` as one batch when applied (the bar, or ⌘S). */
staged?: boolean
/** ⌘Z and ⇧⌘Z, for a consumer that keeps history. */
onUndo?: () => void
onRedo?: () => void
/** A column's new name, from a double-click on its header. */
onRename?: (columnId: string, label: string) => void
/** A column moved, by its header dragged or from its menu: to where in the column
* order. Given, the order is the consumer's (a file's columns are its data);
* without it the table keeps the order the hands made, as a view. */
onMoveColumn?: (columnId: string, to: number) => void
/** A row added at the end, from the button under the last row. */
onAddRow?: () => void
/** A column added at the end, from the + after the last header. */
onAddColumn?: () => void
/** Controlled selection of rows; the table keeps its own when not given. */
selection?: Selection
onSelectionChange?: (selection: Selection) => void
/** The verbs for rows: on a right click for the rows it acts on, and on the
* selection bar while any is selected. */
actions?: (ids: readonly string[]) => readonly MenuEntry[]
/** More verbs for a column, after sorting and moving. */
columnActions?: (columnId: string) => readonly MenuEntry[]
/** A row's tint, a CSS colour (a priority, a state); selection wins over it. */
tint?: (row: R) => string | undefined
density?: TableDensity
/** What one row is called on the selection bar ("row"). */
noun?: string
/** false draws a picture of the table: no focus, no hands (a file's face). */
interactive?: boolean
className?: string
}
/** A row's height per density, px, on the 8px rhythm. */
const ROW_PX: Record<TableDensity, number> = {
compact: 24,
comfortable: 32,
spacious: 40,
}
/** Rows mounted beyond each edge of the view. */
const OVERSCAN = 8
/** How many rows a column's width is read from, and its bounds in characters. */
const WIDTH_SAMPLE = 400
const WIDTH_CH = { min: 3, max: 40 }
/** The narrowest a column can be dragged to, px. */
const MIN_PX = 40
/** The gutter holds the row numbers and, on top, the address box: at least this many
* characters ("AB123"). */
const ADDRESS_CH = 5
const SELECTED_TINT =
"color-mix(in oklab, var(--color-foreground) 9%, transparent)"
const HEAD =
"relative flex min-w-0 items-center gap-1.5 border-b border-foreground/10 px-2.5 text-[12px] font-medium text-foreground/55"
const CELL =
"relative flex min-w-0 items-center border-b border-foreground/6 px-2.5"
/** The one surface the table sits on: the page colour, or the surface around it
* (`[--ag-table-ground:…]` on the table). Sticky parts are filled with it so the rows
* pass under them. */
const GROUND = "bg-[var(--ag-table-ground,var(--color-background))]"
/** A sticky cell is opaque, and wears its row's tint over the ground. */
const STICKY = `sticky z-10 ${GROUND} bg-linear-to-r from-(--ag-table-tint) to-(--ag-table-tint)`
/** The cell cursor: where the keys act, a spreadsheet's current cell, drawn only
* while the table has focus (a table the hands have left shows no box). */
const CURSOR =
"group-focus/table:data-[cursor]:rounded-[4px] group-focus/table:data-[cursor]:outline-[1.5px] group-focus/table:data-[cursor]:-outline-offset-2 group-focus/table:data-[cursor]:outline-foreground/55"
/** A cell inside the block: washed, over whatever the row wears. */
const BLOCK =
"data-[block]:after:pointer-events-none data-[block]:after:absolute data-[block]:after:inset-0 data-[block]:after:bg-foreground/8"
/** A cell holding what find looks for: a wash inset from its edges, under the cursor
* that walks the matches. */
const MATCH =
"data-[match]:before:pointer-events-none data-[match]:before:absolute data-[match]:before:inset-1 data-[match]:before:rounded-[4px] data-[match]:before:bg-foreground/12"
/** A cell whose text the rows do not hold yet: pending, its content faded until the
* consumer answers; refused, a line of the warning tone along its foot. */
const HELD =
"data-[pending]:[&>*]:opacity-50 data-[refused]:shadow-[inset_0_-2px_0_0_var(--color-tone-bad)]"
/** An open editor, over its cell. */
const EDITOR =
"absolute inset-0 z-10 min-w-0 bg-background px-2.5 text-foreground outline-2 -outline-offset-2 outline-ring select-text"
const cellId = (scope: string, row: string, column: string) =>
`${scope}-${row}-${column}`
/** A cell's key in the maps of edits not yet in the rows. */
const cellKey = (row: string, column: string) => `${row}\u0000${column}`
/** The width a column's content asks for, in characters. */
function contentWidth<R>(
column: TableColumn<R>,
rows: readonly R[],
kind: ColumnKind,
) {
let n = column.label.length
if (kind !== "boolean")
for (let i = 0; i < Math.min(rows.length, WIDTH_SAMPLE); i++)
n = Math.max(n, column.text(rows[i] as R).length)
return Math.min(WIDTH_CH.max, Math.max(WIDTH_CH.min, n))
}
/** Tab-separated text as rows of cells (a trailing line break is not a row). */
const matrixOf = (text: string) =>
text
.replace(/\r?\n$/, "")
.split(/\r?\n/)
.map((line) => line.split("\t"))
type At = { row: number; column: number }
/** The grid's own parts, whose keys it answers itself (its cursor moves over them). */
const GRID_ROLES = ["row", "gridcell", "rowheader", "columnheader"]
export function Table<R>({
rows,
rowId,
columns: given,
label,
onEdit,
staged = false,
onUndo,
onRedo,
onRename,
onMoveColumn,
onAddRow,
onAddColumn,
selection: controlled,
onSelectionChange,
actions,
columnActions,
tint,
density = "comfortable",
noun = "row",
interactive = true,
className,
}: TableProps<R>) {
const scope = React.useId().replace(/:/g, "")
const scroller = React.useRef<HTMLDivElement>(null)
const grid = React.useRef<HTMLTableElement>(null)
// An edit commits once: Enter or Tab commit and then hand focus back to the grid,
// whose blur would commit the same edit again.
const settled = React.useRef(false)
// A press on a cell held and moved across others grows the block.
const dragging = React.useRef(false)
const [own, setOwn] = React.useState<Selection>(NONE)
const selection = controlled ?? own
const select = onSelectionChange ?? setOwn
const [sort, setSort] = React.useState<{
column: string
direction: SortDirection
} | null>(null)
const [widths, setWidths] = React.useState<Record<string, number>>({})
const [cursor, setCursor] = React.useState<{
row: string
column: string
} | null>(null)
// The block's other corner; none: the cursor's cell alone.
const [anchor, setAnchor] = React.useState<{
row: string
column: string
} | null>(null)
const [editing, setEditing] = React.useState<{
row: string
column: string
draft: string
/** Opened by typing: a list of choices narrows to the draft at once. */
typed: boolean
} | null>(null)
// Edits the rows do not hold yet. Staged: gestures waiting to be applied (and the
// ones ⌘Z stepped back from). Pending: handed to a consumer whose promise has not
// settled, each cell tagged with the call that last wrote it so an older call
// settling never clears a newer one's text. Refused: the cells of a call that was
// rejected, and its reason.
const [stage, setStage] = React.useState<{
done: readonly (readonly CellEdit[])[]
undone: readonly (readonly CellEdit[])[]
}>({ done: [], undone: [] })
const [pending, setPending] = React.useState<
ReadonlyMap<string, { text: string; call: number }>
>(new Map())
const calls = React.useRef(0)
const [refused, setRefused] = React.useState<{
cells: ReadonlySet<string>
reason: string
} | null>(null)
const [naming, setNaming] = React.useState<string | null>(null)
const [going, setGoing] = React.useState(false)
const [menu, setMenu] = React.useState<{
entries: readonly MenuEntry[]
at: Element | { x: number; y: number }
} | null>(null)
const [view, setView] = React.useState({ top: 0, height: 0, width: 0 })
// A header being dragged: from which column, and before which one it would land.
const [drop, setDrop] = React.useState<{ from: number; to: number } | null>(
null,
)
const dragged = React.useRef(false)
const headRow = React.useRef<HTMLTableRowElement>(null)
const findInput = React.useRef<HTMLInputElement>(null)
// Find: what is looked for and which match the cursor is on.
const [find, setFind] = React.useState<{ query: string; at: number } | null>(
null,
)
const rowPx = ROW_PX[density]
const platform = usePlatform()
// ── The columns in the order shown: the consumer's when it keeps one, else the
// order the hands dragged them into.
const [arranged, setArranged] = React.useState<readonly string[] | null>(null)
const shown = React.useMemo(() => {
if (!arranged || onMoveColumn) return given
const byColumn = new Map(given.map((c) => [c.id, c]))
const kept = arranged.flatMap((id) => byColumn.get(id) ?? [])
return [...kept, ...given.filter((c) => !arranged.includes(c.id))]
}, [given, arranged, onMoveColumn])
// The text as it will be: a staged edit over a pending one over the consumer's own.
// Every reader (the cells, the sort, find, copy, the widths) reads these columns.
const stagedText = React.useMemo(() => {
const m = new Map<string, string>()
for (const gesture of stage.done)
for (const e of gesture) m.set(cellKey(e.row, e.column), e.text)
return m
}, [stage.done])
const held = React.useMemo(() => {
const m = new Map<string, string>()
for (const [k, p] of pending) m.set(k, p.text)
for (const [k, t] of stagedText) m.set(k, t)
return m
}, [pending, stagedText])
const columns = React.useMemo(
() =>
held.size === 0
? shown
: shown.map(
(c): TableColumn<R> => ({
...c,
text: (r) => held.get(cellKey(rowId(r), c.id)) ?? c.text(r),
}),
),
[shown, held, rowId],
)
const moveColumn = (id: string, to: number) => {
if (onMoveColumn) return onMoveColumn(id, to)
const ids = columns.map((c) => c.id).filter((c) => c !== id)
ids.splice(to, 0, id)
setArranged(ids)
}
React.useEffect(() => {
const up = () => {
dragging.current = false
}
window.addEventListener("pointerup", up)
return () => window.removeEventListener("pointerup", up)
}, [])
// ── What is shown: rows by id, each column's kind, the order the sort gives.
const byId = React.useMemo(
() => new Map(rows.map((r) => [rowId(r), r])),
[rows, rowId],
)
const kinds = React.useMemo(
() =>
new Map(
columns.map((c) => [c.id, c.kind ?? kindOf(rows.map(c.text))] as const),
),
[columns, rows],
)
const kindOfColumn = (id: string) => kinds.get(id) as ColumnKind
// The values a text column chooses among: given, or read from its own texts.
const choices = React.useMemo(
() =>
new Map(
columns.map((c) => {
const options =
c.options ??
(kinds.get(c.id) === "text" ? choicesOf(rows.map(c.text)) : null)
return [c.id, options?.length ? options : null] as const
}),
),
[columns, kinds, rows],
)
const columnIndex = React.useMemo(
() => new Map(columns.map((c, i) => [c.id, i])),
[columns],
)
const order = React.useMemo(() => {
const ids = rows.map(rowId)
if (!sort) return ids
const c = columns.find((c) => c.id === sort.column)
if (!c) return ids
return sorted(
ids,
(id) => c.text(byId.get(id) as R),
kinds.get(c.id) as ColumnKind,
sort.direction,
)
}, [rows, rowId, sort, columns, byId, kinds])
const index = React.useMemo(
() => new Map(order.map((id, i) => [id, i])),
[order],
)
const chosen = React.useMemo(
() => inOrder(order, selection),
[order, selection],
)
const editable = (c: TableColumn<R>) => !!onEdit && (c.editable ?? true)
const textAt = (row: number, column: number) =>
(columns[column] as TableColumn<R>).text(
byId.get(order[row] as string) as R,
)
// ── Where the cursor and the block are, as places in the shown order.
const placeOf = (cell: { row: string; column: string } | null): At | null => {
if (!cell) return null
const row = index.get(cell.row)
const column = columnIndex.get(cell.column)
return row === undefined || column === undefined ? null : { row, column }
}
const here = placeOf(cursor) ?? { row: 0, column: 0 }
const corner = placeOf(anchor) ?? here
const block = {
top: Math.min(here.row, corner.row),
bottom: Math.max(here.row, corner.row),
left: Math.min(here.column, corner.column),
right: Math.max(here.column, corner.column),
}
const wide = block.top !== block.bottom || block.left !== block.right
const address = !cursor
? ""
: wide
? `${columnName(block.left)}${block.top + 1}:${columnName(block.right)}${block.bottom + 1}`
: `${columnName(here.column)}${here.row + 1}`
// ── The template every row shares.
const gutterCh =
Math.max(String(Math.max(order.length, 1)).length, ADDRESS_CH) + 2
const tracks = columns.map((c) =>
widths[c.id] !== undefined
? `${widths[c.id]}px`
: (c.width ??
`calc(${contentWidth(c, rows, kindOfColumn(c.id))}ch + 1.25rem)`),
)
const template = [
`${gutterCh}ch`,
...tracks,
...(onAddColumn && interactive ? ["2rem"] : []),
].join(" ")
// ── The window: what the view shows, plus the cursor's row wherever it is.
React.useLayoutEffect(() => {
const el = scroller.current
if (!el) return
const measure = () =>
setView({
top: el.scrollTop,
height: el.clientHeight,
width: el.clientWidth,
})
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
const first = Math.max(0, Math.floor(view.top / rowPx) - 1 - OVERSCAN)
const last = Math.min(
order.length,
Math.ceil((view.top + view.height) / rowPx) + OVERSCAN,
)
const mounted: number[] = []
for (let i = first; i < last; i++) mounted.push(i)
const cursorAt = cursor ? index.get(cursor.row) : undefined
if (cursorAt !== undefined && (cursorAt < first || cursorAt >= last))
mounted.push(cursorAt)
// The cursor's cell comes into view when it moves, clear of the sticky header and
// first column (their sizes are its scroll margins).
React.useEffect(() => {
if (!cursor) return
document
.getElementById(cellId(scope, cursor.row, cursor.column))
?.scrollIntoView({ block: "nearest", inline: "nearest" })
}, [cursor, scope])
// ── Moving: the cursor to a place; `grow` keeps the block's other corner where it
// was (⇧), else the block and the rows are let go.
const cellAt = (at: At) => ({
row: order[Math.max(0, Math.min(order.length - 1, at.row))] as string,
column: (
columns[
Math.max(0, Math.min(columns.length - 1, at.column))
] as TableColumn<R>
).id,
})
const moveTo = (at: At, grow = false) => {
if (order.length === 0 || columns.length === 0) return
const cell = cellAt(at)
if (grow) setAnchor((a) => a ?? cursor ?? cell)
else {
setAnchor(null)
if (selection.ids.size > 0 || selection.anchor !== cell.row)
select({ ids: new Set(), anchor: cell.row, cursor: cell.row })
}
setCursor(cell)
}
/** Excel's ⌘-arrow: across the run of filled cells to its end, or across the empty
* ones to the next filled, or to the table's edge. */
const edge = (from: At, dRow: number, dColumn: number): At => {
const inside = (r: number, c: number) =>
r >= 0 && r < order.length && c >= 0 && c < columns.length
const filled = (r: number, c: number) => textAt(r, c).trim() !== ""
let { row: r, column: c } = from
if (!inside(r + dRow, c + dColumn)) return from
if (filled(r, c) && filled(r + dRow, c + dColumn)) {
while (inside(r + dRow, c + dColumn) && filled(r + dRow, c + dColumn)) {
r += dRow
c += dColumn
}
return { row: r, column: c }
}
r += dRow
c += dColumn
while (inside(r + dRow, c + dColumn) && !filled(r, c)) {
r += dRow
c += dColumn
}
return { row: r, column: c }
}
const focusGrid = () => grid.current?.focus({ preventScroll: true })
// ── Changing cells: every gesture is one call, or one staged step.
/** A cell's text as the consumer's rows hold it, under every held edit. */
const baseOf = (row: string, column: string) => {
const r = byId.get(row)
const c = shown.find((c) => c.id === column)
return r === undefined || !c ? undefined : c.text(r)
}
/** Asks the consumer; a promise holds the cells at their new text until it
* settles, and a rejection gives them back and says why. */
const hand = (edits: readonly CellEdit[]) => {
setRefused(null)
const answer = onEdit?.(edits)
if (!(answer instanceof Promise)) return
const call = ++calls.current
setPending((p) => {
const next = new Map(p)
for (const e of edits)
next.set(cellKey(e.row, e.column), { text: e.text, call })
return next
})
const settle = () =>
setPending((p) => {
const next = new Map(p)
for (const e of edits) {
const k = cellKey(e.row, e.column)
if (next.get(k)?.call === call) next.delete(k)
}
return next
})
answer.then(settle, (error: unknown) => {
settle()
setRefused({
cells: new Set(edits.map((e) => cellKey(e.row, e.column))),
reason: error instanceof Error ? error.message : String(error),
})
})
}
const change = (edits: CellEdit[]) => {
const real = edits.filter(
(e) =>
e.text !==
textAt(index.get(e.row) as number, columnIndex.get(e.column) as number),
)
if (real.length === 0) return
if (staged) setStage((s) => ({ done: [...s.done, real], undone: [] }))
else hand(real)
}
// What staging has changed: the cells whose staged text differs from the rows'.
const changed = [...stagedText].flatMap(([k, text]) => {
const [row, column] = k.split("\u0000") as [string, string]
const was = baseOf(row, column)
return was === undefined || was === text ? [] : [{ row, column, text }]
})
const apply = () => {
setStage({ done: [], undone: [] })
if (changed.length > 0) hand(changed)
}
const discard = () => setStage({ done: [], undone: [] })
const unstage = (back: boolean) =>
setStage((s) =>
back
? {
done: s.done.slice(0, -1),
undone: [...s.undone, s.done.at(-1) as readonly CellEdit[]],
}
: {
done: [...s.done, s.undone.at(-1) as readonly CellEdit[]],
undone: s.undone.slice(0, -1),
},
)
const flip = (at: At) => {
const c = columns[at.column] as TableColumn<R>
if (!editable(c)) return
const cell = cellAt(at)
change([
{
...cell,
text: flipped(textAt(at.row, at.column), truthyOf(rows.map(c.text))),
},
])
}
/** Every editable cell of the block, given its new text. */
const overBlock = (text: (row: number, column: number) => string) => {
const edits: CellEdit[] = []
for (let r = block.top; r <= block.bottom; r++)
for (let c = block.left; c <= block.right; c++)
if (editable(columns[c] as TableColumn<R>))
edits.push({ ...cellAt({ row: r, column: c }), text: text(r, c) })
return edits
}
const startEdit = (at: At, draft?: string) => {
const c = columns[at.column] as TableColumn<R>
if (!editable(c)) return
if (kindOfColumn(c.id) === "boolean") return flip(at)
settled.current = false
setEditing({
...cellAt(at),
draft: draft ?? textAt(at.row, at.column),
typed: draft !== undefined,
})
}
/** An editor closed by a key: Enter commits and goes down (⇧ up), Tab right (⇧
* left), Esc gives up; the grid has the keys again. */
const finish = (at: At, text: string | null, key: string, back: boolean) => {
if (text === null) giveUp()
else commit(text)
focusGrid()
if (text === null) return
const d = back ? -1 : 1
moveTo(
key === "Tab"
? { ...at, column: at.column + d }
: { ...at, row: at.row + d },
)
}
const commit = (text: string) => {
if (!editing || settled.current) return
settled.current = true
change([{ row: editing.row, column: editing.column, text }])
setEditing(null)
}
const giveUp = () => {
settled.current = true
setEditing(null)
}
// ── Go to an address typed in the corner box.
const goTo = (text: string) => {
const where = parseAddress(text)
if (!where) return false
const resolve = (p: { row?: number; column?: number }): At => ({
row: p.row ?? here.row,
column: p.column ?? here.column,
})
const from = resolve(where.from)
const to = resolve(where.to)
if (
[from, to].some(
(p) => p.row >= order.length || p.column >= columns.length,
)
)
return false
moveTo(from)
if (from.row !== to.row || from.column !== to.column) {
setAnchor(cellAt(from))
setCursor(cellAt(to))
}
return true
}
// ── The keyboard, on the grid. An open editor keeps its own keys, as does a control
// inside a cell (a row's verb), and a press from a portal (a menu) is never the
// grid's (key-router's `ownedHere`, the registry's one rule).
const onKeyDown = (e: React.KeyboardEvent) => {
if (!e.currentTarget.contains(e.target as Node)) return
if (ownedHere(e.nativeEvent, e.currentTarget, GRID_ROLES)) return
const mod = e.metaKey || e.ctrlKey
const grow = e.shiftKey
const page = Math.max(1, Math.floor(view.height / rowPx) - 2)
const run = (f: () => void) => {
e.preventDefault()
f()
}
// A key a row verb draws runs it first, for what it would act on: the selected
// rows, else the cursor's. The consumer drew it, so it is the consumer's.
const acted = chosen.length > 0 ? chosen : cursor ? [cursor.row] : []
const verb =
actions && acted.length > 0
? verbFor(actions(acted), e, platform)
: undefined
if (verb) return run(verb.onSelect)
const step = (dRow: number, dColumn: number) =>
run(() =>
moveTo(
mod
? edge(here, dRow, dColumn)
: { row: here.row + dRow, column: here.column + dColumn },
grow,
),
)
const letter = e.key.length === 1 ? e.key.toLowerCase() : ""
if (mod && !e.altKey)
switch (letter) {
case "a":
return run(() => select(all(order, selection)))
case "g":
return run(() => setGoing(true))
case "f":
return run(() => {
setFind((f) => f ?? { query: "", at: 0 })
findInput.current?.select()
})
case "z":
// Staged steps first: they are the newest, and the consumer has not seen them.
if ((e.shiftKey ? stage.undone : stage.done).length > 0)
return run(() => unstage(!e.shiftKey))
if (e.shiftKey ? !onRedo : !onUndo) return
return run(() => (e.shiftKey ? onRedo?.() : onUndo?.()))
case "s":
if (!staged || changed.length === 0) return
return run(apply)
case "d":
case "r": {
if (!onEdit) return
const down = letter === "d"
if (down ? block.top === block.bottom : block.left === block.right)
return
return run(() =>
change(
overBlock((r, c) =>
down ? textAt(block.top, c) : textAt(r, block.left),
),
),
)
}
}
switch (e.key) {
case "ArrowDown":
return step(1, 0)
case "ArrowUp":
return step(-1, 0)
case "ArrowRight":
return step(0, 1)
case "ArrowLeft":
return step(0, -1)
case "Home":
return run(() =>
moveTo(mod ? { row: 0, column: 0 } : { ...here, column: 0 }, grow),
)
case "End":
return run(() =>
moveTo(
mod
? { row: order.length - 1, column: columns.length - 1 }
: { ...here, column: columns.length - 1 },
grow,
),
)
case "PageDown":
return run(() => moveTo({ ...here, row: here.row + page }, grow))
case "PageUp":
return run(() => moveTo({ ...here, row: here.row - page }, grow))
case "Tab": {
const next = here.column + (e.shiftKey ? -1 : 1)
if (next >= 0 && next < columns.length)
return run(() => moveTo({ ...here, column: next }))
const wrap = here.row + (e.shiftKey ? -1 : 1)
if (wrap < 0 || wrap >= order.length) return
return run(() =>
moveTo({
row: wrap,
column: e.shiftKey ? columns.length - 1 : 0,
}),
)
}
case "Enter":
case "F2":
if (!cursor) return
return run(() => startEdit(here))
case " ":
if (!cursor) return
if (e.shiftKey) {
const ids = order.slice(block.top, block.bottom + 1)
return run(() =>
select({
ids: new Set(ids),
anchor: ids[0],
cursor: ids.at(-1),
}),
)
}
return run(() =>
kindOfColumn(cursor.column) === "boolean" &&
editable(columns[here.column] as TableColumn<R>)
? flip(here)
: select(toggle(selection, cursor.row)),
)
case "Escape":
if (anchor) return run(() => setAnchor(null))
if (selection.ids.size === 0) return
return run(() => select({ ...NONE, anchor: selection.anchor }))
case "Backspace":
case "Delete":
if (!cursor || mod || !onEdit) return
return run(() => change(overBlock(() => "")))
case "ContextMenu":
case "F10":
if (e.key === "F10" && !e.shiftKey) return
if (!cursor || !actions) return
return run(() => {
const el = document.getElementById(
cellId(scope, cursor.row, cursor.column),
)
if (el)
setMenu({
entries: actions(actsOn(order, selection, cursor.row)),
at: el,
})
})
}
if (
cursor &&
letter &&
!mod &&
!e.altKey &&
editable(columns[here.column] as TableColumn<R>) &&
kindOfColumn(cursor.column) !== "boolean"
)
run(() => startEdit(here, e.key))
}
const onCopy = (e: React.ClipboardEvent) => {
if (typingIn(e.target) || !cursor) return
const line = (r: number, from: number, to: number) => {
const cells: string[] = []
for (let c = from; c <= to; c++) cells.push(textAt(r, c))
return cells.join("\t")
}
const lines: string[] = []
if (!wide && chosen.length > 0)
for (const id of chosen)
lines.push(line(index.get(id) as number, 0, columns.length - 1))
else
for (let r = block.top; r <= block.bottom; r++)
lines.push(line(r, block.left, block.right))
e.preventDefault()
e.clipboardData.setData("text/plain", lines.join("\n"))
}
const onPaste = (e: React.ClipboardEvent) => {
if (typingIn(e.target) || !cursor || !onEdit) return
const m = matrixOf(e.clipboardData.getData("text/plain"))
e.preventDefault()
// One value fills the block; a block of text lands from the block's corner.
if (m.length === 1 && (m[0] as string[]).length === 1)
return change(overBlock(() => (m[0] as string[])[0] as string))
const edits: CellEdit[] = []
m.forEach((line, dr) => {
line.forEach((text, dc) => {
const r = block.top + dr
const c = block.left + dc
if (r >= order.length || c >= columns.length) return
if (!editable(columns[c] as TableColumn<R>)) return
edits.push({ ...cellAt({ row: r, column: c }), text })
})
})
change(edits)
}
// ── The pointer.
const onCellDown = (at: At, e: React.PointerEvent) => {
if (e.button !== 0) return
const cell = cellAt(at)
if (editing?.row === cell.row && editing.column === cell.column) return
e.preventDefault()
focusGrid()
if (e.metaKey || e.ctrlKey) {
setCursor(cell)
return select(toggle(selection, cell.row))
}
if (e.shiftKey) return moveTo(at, true)
moveTo(at)
setAnchor(cell)
dragging.current = true
}
const onCellEnter = (at: At, e: React.PointerEvent) => {
if (!dragging.current || !(e.buttons & 1)) return
setCursor(cellAt(at))
}
const onGutterClick = (row: string, e: React.MouseEvent) => {
const mod = e.metaKey || e.ctrlKey
setCursor({ row, column: cursor?.column ?? (columns[0]?.id as string) })
setAnchor(null)
focusGrid()
select(
e.shiftKey
? range(order, selection, row, mod)
: mod
? toggle(selection, row)
: only(row),
)
}
const onRowMenu = (row: string, e: React.MouseEvent) => {
if (!actions) return
e.preventDefault()
setMenu({
entries: actions(actsOn(order, selection, row)),
at: { x: e.clientX, y: e.clientY },
})
}
const cycleSort = (column: string) =>
setSort((s) =>
s?.column !== column
? { column, direction: "asc" }
: s.direction === "asc"
? { column, direction: "desc" }
: null,
)
const headerMenu = (column: string, at: { x: number; y: number }) => {
const i = columnIndex.get(column) as number
const moves: MenuEntry[] = [
{
icon: <ArrowLeft />,
label: "move left",
disabled: i === 0,
onSelect: () => moveColumn(column, i - 1),
},
{
icon: <ArrowRight />,
label: "move right",
disabled: i === columns.length - 1,
onSelect: () => moveColumn(column, i + 1),
},
]
const more = columnActions?.(column) ?? []
setMenu({
at,
entries: [
{
icon: <ArrowUpNarrowWide />,
label: "sort ascending",
onSelect: () => setSort({ column, direction: "asc" }),
},
{
icon: <ArrowDownWideNarrow />,
label: "sort descending",
onSelect: () => setSort({ column, direction: "desc" }),
},
...(sort?.column === column
? [
{
icon: <X />,
label: "as given",
onSelect: () => setSort(null),
},
]
: []),
"separator",
...moves,
...(more.length ? ["separator" as const, ...more] : []),
],
})
}
// A header dragged sideways moves its column: past a few pixels it is a drag, not a
// click (the sort waits for a click); the column lands before the header the
// pointer is over the first half of.
const dragHead = (from: number, e: React.PointerEvent) => {
if (e.button !== 0) return
const target = e.target as HTMLElement
if (target.closest("[data-slot=table-resize], input")) return
const x0 = e.clientX
let to: number | null = null
dragged.current = false
const move = (m: PointerEvent) => {
if (!dragged.current && Math.abs(m.clientX - x0) < 4) return
dragged.current = true
const heads = [
...(headRow.current?.querySelectorAll<HTMLElement>(
"[data-slot=table-head]",
) ?? []),
]
const at = heads.findIndex((h) => {
const r = h.getBoundingClientRect()
return m.clientX < r.left + r.width / 2
})
to = at < 0 ? heads.length : at
setDrop({ from, to })
}
const up = () => {
window.removeEventListener("pointermove", move)
window.removeEventListener("pointerup", up)
setDrop(null)
if (!dragged.current || to === null) return
const landing = to > from ? to - 1 : to
if (landing !== from)
moveColumn((columns[from] as TableColumn<R>).id, landing)
}
window.addEventListener("pointermove", move)
window.addEventListener("pointerup", up)
}
// ── Find: every cell holding the words, in the shown order, row by row.
const matches = React.useMemo(() => {
const q = find?.query.trim().toLowerCase()
if (!q) return []
const found: At[] = []
order.forEach((id, row) => {
const r = byId.get(id) as R
columns.forEach((c, column) => {
if (c.text(r).toLowerCase().includes(q)) found.push({ row, column })
})
})
return found
}, [find?.query, order, byId, columns])
const matched = React.useMemo(
() => new Set(matches.map((m) => `${m.row}:${m.column}`)),
[matches],
)
// What is typed is found at once: the cursor goes to the first match, as a browser's
// find does, and Enter walks on from there.
// biome-ignore lint/correctness/useExhaustiveDependencies: only a new set of matches moves the cursor
React.useEffect(() => {
const found = matches[0]
if (found) moveTo(found)
}, [matches])
const showMatch = (at: number) => {
if (matches.length === 0) return
const i = ((at % matches.length) + matches.length) % matches.length
setFind((f) => (f ? { ...f, at: i } : f))
moveTo(matches[i] as At)
}
// A column's edge: dragged, it sets the width; double-clicked, it fits again.
const resize = (column: string, e: React.PointerEvent<HTMLElement>) => {
e.preventDefault()
e.stopPropagation()
const head = e.currentTarget.parentElement as HTMLElement
const from = head.getBoundingClientRect().width
const x0 = e.clientX
const el = e.currentTarget
el.setPointerCapture(e.pointerId)
const move = (m: PointerEvent) =>
setWidths((w) => ({
...w,
[column]: Math.max(MIN_PX, Math.round(from + m.clientX - x0)),
}))
const up = () => {
el.removeEventListener("pointermove", move)
el.removeEventListener("pointerup", up)
el.removeEventListener("pointercancel", up)
}
el.addEventListener("pointermove", move)
el.addEventListener("pointerup", up)
el.addEventListener("pointercancel", up)
}
const fit = (column: string) =>
setWidths((w) => {
const { [column]: _, ...rest } = w
return rest
})
const firstWidth = tracks[0] ?? "0px"
return (
<div
ref={scroller}
data-slot="table"
onScroll={(e) => {
const top = e.currentTarget.scrollTop
if (top !== view.top) setView((v) => ({ ...v, top }))
}}
className={cn(
// edge: its foot holds the pinned bars (refused, staged, chosen), which a
// fade on this scroller would fade with the rows; owed: the bars moved
// outside it, then `ag-edge-fade-xy` with the header and the first
// column as its covers.
"relative flex min-h-0 flex-col overflow-auto text-[13px]",
NO_SCROLLBAR,
GROUND,
!interactive && "pointer-events-none",
className,
)}
style={
{
"--ag-table-cols": template,
"--ag-table-row": `${rowPx}px`,
"--ag-table-gutter": `${gutterCh}ch`,
"--ag-table-first": firstWidth,
"--ag-table-view": `${view.width}px`,
} as React.CSSProperties
}
>
{interactive && <ScrollThumbs scroller={scroller} />}
{interactive && find && (
// Pinned over the view's top right corner whichever way it scrolls.
<div className="pointer-events-none sticky top-0 left-0 z-40 h-0 w-(--ag-table-view) shrink-0">
<search
data-slot="table-find"
className="pointer-events-auto absolute top-1 right-2 flex items-center gap-1 rounded-lg border border-border bg-popover/95 py-0.5 pr-0.5 pl-2 text-[12px] text-popover-foreground shadow-md backdrop-blur-md"
>
<input
ref={findInput}
// biome-ignore lint/a11y/noAutofocus: find opens on the key that asked for it
autoFocus
aria-label="find in the table"
placeholder="find"
value={find.query}
onChange={(e) => {
const query = e.target.value
setFind({ query, at: 0 })
}}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === "Escape") {
e.preventDefault()
setFind(null)
focusGrid()
return
}
if (e.key !== "Enter") return
e.preventDefault()
showMatch(find.at + (e.shiftKey ? -1 : 1))
}}
className="w-36 min-w-0 bg-transparent outline-none placeholder:text-foreground/35"
/>
<span className="px-1 text-foreground/45 tabular-nums">
{find.query.trim() === ""
? ""
: matches.length === 0
? "none"
: `${find.at + 1} of ${matches.length}`}
</span>
<button
type="button"
aria-label="previous match"
disabled={matches.length === 0}
onClick={() => showMatch(find.at - 1)}
className="grid size-6 cursor-pointer place-items-center rounded-md text-foreground/55 hover:bg-foreground/8 hover:text-foreground disabled:opacity-40"
>
<ArrowUp className="size-3.5" />
</button>
<button
type="button"
aria-label="next match"
disabled={matches.length === 0}
onClick={() => showMatch(find.at + 1)}
className="grid size-6 cursor-pointer place-items-center rounded-md text-foreground/55 hover:bg-foreground/8 hover:text-foreground disabled:opacity-40"
>
<ArrowDown className="size-3.5" />
</button>
<button
type="button"
aria-label="close find"
onClick={() => {
setFind(null)
focusGrid()
}}
className="grid size-6 cursor-pointer place-items-center rounded-md text-foreground/55 hover:bg-foreground/8 hover:text-foreground"
>
<X className="size-3.5" />
</button>
</search>
</div>
)}
<table
ref={grid}
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: WAI-ARIA's data grid is a table given the grid role; its cells are walked with the keys
role="grid"
aria-label={label}
aria-rowcount={order.length + 1}
aria-colcount={columns.length}
aria-multiselectable={interactive || undefined}
aria-readonly={!onEdit || undefined}
aria-activedescendant={
cursor ? cellId(scope, cursor.row, cursor.column) : undefined
}
tabIndex={interactive ? 0 : -1}
onKeyDown={interactive ? onKeyDown : undefined}
onCopy={interactive ? onCopy : undefined}
onPaste={interactive ? onPaste : undefined}
onFocus={() => {
if (!cursor && order.length > 0 && columns.length > 0)
setCursor(cellAt({ row: 0, column: 0 }))
}}
className="group/table relative block w-max min-w-full shrink-0 border-collapse outline-none select-none"
>
<thead
data-slot="table-header"
className={cn("sticky top-0 z-20 block", GROUND)}
>
<tr
ref={headRow}
aria-rowindex={1}
className="grid h-(--ag-table-row) grid-cols-(--ag-table-cols)"
>
<th
data-slot="table-address"
className={cn(
HEAD,
GROUND,
"sticky left-0 z-30 justify-end px-1.5 font-normal text-[11px] text-foreground/40 tabular-nums",
)}
>
{going ? (
<input
// biome-ignore lint/a11y/noAutofocus: go to opens on the key that asked for it
autoFocus
aria-label="go to a cell, a row, a column or a block"
placeholder={address}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === "Escape") {
setGoing(false)
focusGrid()
}
if (e.key !== "Enter") return
e.preventDefault()
if (!goTo(e.currentTarget.value)) {
e.currentTarget.setAttribute("aria-invalid", "true")
return
}
setGoing(false)
focusGrid()
}}
onInput={(e) =>
e.currentTarget.removeAttribute("aria-invalid")
}
onBlur={() => setGoing(false)}
className="absolute inset-0 min-w-0 bg-background px-1.5 text-right text-foreground uppercase outline-[1.5px] -outline-offset-2 outline-ring placeholder:text-foreground/30 aria-invalid:outline-tone-bad"
/>
) : (
<span className="truncate" title="go to (mod+g)">
{address}
</span>
)}
</th>
{columns.map((c, i) => {
const sortedHere = sort?.column === c.id ? sort.direction : null
const kind = kindOfColumn(c.id)
return (
<th
key={c.id}
scope="col"
aria-colindex={i + 1}
aria-sort={
sortedHere === "asc"
? "ascending"
: sortedHere === "desc"
? "descending"
: undefined
}
data-slot="table-head"
data-dragging={drop?.from === i || undefined}
data-drop={
drop && drop.to !== drop.from && drop.to !== drop.from + 1
? drop.to === i
? "before"
: drop.to === columns.length && i === columns.length - 1
? "after"
: undefined
: undefined
}
onPointerDown={(e) => interactive && dragHead(i, e)}
onClick={() => {
if (!interactive) return
if (dragged.current) {
dragged.current = false
return
}
cycleSort(c.id)
}}
onDoubleClick={() =>
interactive && onRename && setNaming(c.id)
}
onContextMenu={(e) => {
if (!interactive) return
e.preventDefault()
headerMenu(c.id, { x: e.clientX, y: e.clientY })
}}
className={cn(
HEAD,
"cursor-default select-none hover:text-foreground/80 data-dragging:opacity-40",
// where a dragged column would land: a line at the edge
"data-[drop=before]:before:absolute data-[drop=before]:before:inset-y-1 data-[drop=before]:before:-left-px data-[drop=before]:before:w-0.5 data-[drop=before]:before:rounded-full data-[drop=before]:before:bg-foreground/60",
"data-[drop=after]:before:absolute data-[drop=after]:before:inset-y-1 data-[drop=after]:before:-right-px data-[drop=after]:before:w-0.5 data-[drop=after]:before:rounded-full data-[drop=after]:before:bg-foreground/60",
i === 0 &&
cn(GROUND, "sticky left-(--ag-table-gutter) z-30"),
)}
>
<span
aria-hidden="true"
className="shrink-0 font-normal text-[10px] text-foreground/30"
>
{columnName(i)}
</span>
{naming === c.id ? (
<input
// biome-ignore lint/a11y/noAutofocus: a rename opens on the gesture that asked for it
autoFocus
aria-label={`rename ${c.label}`}
defaultValue={c.label}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === "Enter") e.currentTarget.blur()
if (e.key === "Escape") {
e.currentTarget.value = c.label
e.currentTarget.blur()
}
}}
onBlur={(e) => {
const v = e.currentTarget.value.trim()
if (v && v !== c.label) onRename?.(c.id, v)
setNaming(null)
focusGrid()
}}
className="absolute inset-0 bg-background px-2.5 font-medium text-foreground outline-2 -outline-offset-2 outline-ring"
/>
) : (
<span
className={cn(
"min-w-0 flex-1 truncate",
kind === "number" && "text-right",
kind === "boolean" && "text-center",
)}
>
{c.label}
</span>
)}
{sortedHere &&
(sortedHere === "asc" ? (
<ArrowUp className="size-3 shrink-0" />
) : (
<ArrowDown className="size-3 shrink-0" />
))}
{interactive && (
<span
aria-hidden="true"
data-slot="table-resize"
onPointerDown={(e) => resize(c.id, e)}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => {
e.stopPropagation()
fit(c.id)
}}
className="absolute inset-y-1 -right-1 z-10 w-2 cursor-col-resize after:absolute after:inset-y-0 after:left-1/2 after:w-px after:bg-foreground/10 hover:after:bg-foreground/40"
/>
)}
</th>
)
})}
{onAddColumn && interactive && (
<th className={cn(HEAD, "justify-center px-0")}>
<button
type="button"
aria-label="add a column"
onClick={onAddColumn}
className="grid size-6 cursor-pointer place-items-center rounded-md hover:bg-foreground/8 hover:text-foreground"
>
<Plus className="size-3.5" />
</button>
</th>
)}
</tr>
</thead>
<tbody
data-slot="table-body"
className="relative block h-(--ag-table-body)"
style={
{
"--ag-table-body": `${order.length * rowPx}px`,
} as React.CSSProperties
}
>
{mounted.map((i) => {
const id = order[i] as string
const row = byId.get(id) as R
const selected = selection.ids.has(id)
return (
<tr
key={id}
aria-rowindex={i + 2}
aria-selected={interactive ? selected : undefined}
data-slot="table-row"
data-selected={selected || undefined}
onContextMenu={(e) => interactive && onRowMenu(id, e)}
className="absolute inset-x-0 top-0 grid h-(--ag-table-row) translate-y-(--ag-table-top) grid-cols-(--ag-table-cols) bg-(--ag-table-tint)"
style={
{
"--ag-table-top": `${i * rowPx}px`,
"--ag-table-tint": selected
? SELECTED_TINT
: (tint?.(row) ?? "transparent"),
} as React.CSSProperties
}
>
<th
scope="row"
onClick={(e) => onGutterClick(id, e)}
className={cn(
CELL,
STICKY,
"left-0 cursor-default justify-end text-[11px] text-foreground/35 tabular-nums",
selected && "text-foreground/70",
)}
>
{i + 1}
</th>
{columns.map((c, ci) => {
const kind = kindOfColumn(c.id)
const text = c.text(row)
const at = { row: i, column: ci }
const isCursor = cursor?.row === id && cursor.column === c.id
const inBlock =
wide &&
i >= block.top &&
i <= block.bottom &&
ci >= block.left &&
ci <= block.right
const edit =
editing?.row === id && editing.column === c.id
? editing
: null
const k = cellKey(id, c.id)
const options = choices.get(c.id) ?? null
// Held: the cell shows text the consumer's rows do not hold yet,
// so it is drawn from that text, never from the row (`render`).
const isHeld = held.has(k)
const was = stagedText.has(k) ? baseOf(id, c.id) : undefined
const isStaged = was !== undefined && was !== text
const isPending = !isStaged && pending.has(k)
const isRefused = refused?.cells.has(k) ?? false
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: the grid owns the keys
<td
key={c.id}
id={cellId(scope, id, c.id)}
aria-colindex={ci + 1}
data-slot="table-cell"
data-kind={kind}
data-cursor={(interactive && isCursor) || undefined}
data-block={inBlock || undefined}
data-match={matched.has(`${i}:${ci}`) || undefined}
data-staged={isStaged || undefined}
data-pending={isPending || undefined}
data-refused={isRefused || undefined}
title={
isRefused
? `not saved: ${refused?.reason}`
: isStaged
? `was ${was === "" ? "empty" : was}`
: undefined
}
onPointerDown={(e) => onCellDown(at, e)}
onPointerEnter={(e) => onCellEnter(at, e)}
onClick={(e) => {
if (
!e.shiftKey &&
!e.metaKey &&
!e.ctrlKey &&
kind === "boolean" &&
(e.target as HTMLElement).closest(
"[data-slot=table-check]",
)
)
flip(at)
}}
onDoubleClick={() => startEdit(at)}
className={cn(
CELL,
CURSOR,
BLOCK,
MATCH,
HELD,
"scroll-mt-(--ag-table-row) scroll-ml-[calc(var(--ag-table-gutter)+var(--ag-table-first))]",
kind === "number" && "justify-end tabular-nums",
kind === "date" && "tabular-nums",
kind === "boolean" && "justify-center",
ci === 0 &&
cn(STICKY, "left-(--ag-table-gutter) scroll-ml-0"),
)}
>
{edit && options ? (
<ChoiceEditor
label={c.label}
draft={edit.draft}
typed={edit.typed}
options={options}
onDone={(value, key, back) =>
finish(at, value, key, back)
}
onBlur={commit}
/>
) : edit ? (
<input
// biome-ignore lint/a11y/noAutofocus: an edit opens on the gesture that asked for it
autoFocus
aria-label={c.label}
defaultValue={edit.draft}
onFocus={(e) => {
const end = e.currentTarget.value.length
e.currentTarget.setSelectionRange(end, end)
}}
onKeyDown={(e) => {
if (
e.key !== "Escape" &&
e.key !== "Enter" &&
e.key !== "Tab"
)
return
e.preventDefault()
finish(
at,
e.key === "Escape" ? null : e.currentTarget.value,
e.key,
e.shiftKey,
)
}}
onBlur={(e) => commit(e.currentTarget.value)}
className={cn(
EDITOR,
kind === "number" && "text-right tabular-nums",
)}
/>
) : c.render && !isHeld ? (
c.render(row)
) : kind === "boolean" ? (
<Box on={truthOf(text) === true} label={text} />
) : options ? (
text.trim() !== "" && <Badge>{text}</Badge>
) : (
<span className="truncate">{text}</span>
)}
{isStaged && (
<span
data-slot="table-staged"
className="pointer-events-none absolute top-0 right-0 size-2 bg-foreground/45 [clip-path:polygon(0_0,100%_0,100%_100%)]"
>
<span className="sr-only">
changed, was {was === "" ? "empty" : was}
</span>
</span>
)}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
{onAddRow && interactive && (
<button
type="button"
onClick={onAddRow}
className="sticky left-0 flex h-(--ag-table-row) w-fit shrink-0 cursor-pointer items-center gap-1.5 px-2.5 text-[12px] text-foreground/45 hover:text-foreground"
>
<Plus className="size-3.5" />
add a {noun}
</button>
)}
{interactive &&
(changed.length > 0 || refused || (actions && chosen.length > 0)) && (
// Pinned to the view's foot, one place whatever the table's length and
// whichever way it scrolls: a box of no height pushed to the end of the
// column (mt-auto: a short table does not lift it), sticky to the bottom and
// the left, the view's width. The bars stack there: what was refused, what
// waits to be applied, what is selected.
<div className="pointer-events-none sticky bottom-0 left-0 z-30 mt-auto h-0 w-(--ag-table-view) shrink-0">
<div className="absolute inset-x-3 bottom-3 flex flex-col items-center gap-2 *:pointer-events-auto">
{refused && (
<div
role="alert"
data-slot="table-refused"
className={cn(BAR, "pl-2.5 text-[13px]")}
>
<span className="text-tone-bad">
{refused.cells.size === 1
? "a cell was"
: `${refused.cells.size} cells were`}{" "}
not saved
</span>
<span className="px-1.5 text-foreground/60">
{refused.reason}
</span>
<span aria-hidden="true" className={BAR_RULE} />
<BarVerb
entry={{
icon: <X />,
label: "dismiss",
onSelect: () => setRefused(null),
}}
/>
</div>
)}
{changed.length > 0 && (
<div
role="toolbar"
aria-label="unsaved changes"
data-slot="table-staged-bar"
className={BAR}
>
<span className="px-2 text-[13px] font-medium tabular-nums">
{changed.length}{" "}
{changed.length === 1 ? "change" : "changes"}
</span>
<span aria-hidden="true" className={BAR_RULE} />
<BarVerb
entry={{
icon: <Undo2 />,
label: "discard",
onSelect: () => {
discard()
focusGrid()
},
}}
/>
<BarVerb
entry={{
icon: <Check />,
label: "apply",
hint: "mod+s",
onSelect: () => {
apply()
focusGrid()
},
}}
/>
</div>
)}
{actions && chosen.length > 0 && (
<SelectionBar
count={chosen.length}
hidden={selection.ids.size - chosen.length}
noun={noun}
entries={actions(chosen)}
onClear={() => {
select({ ...NONE, anchor: selection.anchor })
focusGrid()
}}
/>
)}
</div>
</div>
)}
{interactive && (
<Menu
entries={menu?.entries ?? []}
anchor={menu?.at ?? null}
onClose={() => setMenu(null)}
returnTo={() => grid.current}
/>
)}
</div>
)
}
/** A choice's value: a quiet pill, so a column of a few values reads as states. */
function Badge({ children }: { children: string }) {
return (
<span
data-slot="table-badge"
className="truncate rounded-full bg-foreground/7 px-2 py-px text-[0.92em] text-foreground/75"
>
{children}
</span>
)
}
/** A choice cell, edited: what is typed narrows the column's values, ↑ ↓ walk them,
* Enter or Tab take the one marked (what was typed when none is), a press on one
* takes it, Esc gives up. The list hangs under the cell outside the table, so the
* table's edge never clips it. */
function ChoiceEditor({
label,
draft,
typed,
options,
onDone,
onBlur,
}: {
label: string
draft: string
typed: boolean
options: readonly string[]
onDone: (value: string | null, key: string, back: boolean) => void
onBlur: (value: string) => void
}) {
const id = React.useId()
const input = React.useRef<HTMLInputElement>(null)
const [query, setQuery] = React.useState(draft)
// Opened on a cell, every value is offered with the cell's own marked; opened by
// typing, only the ones holding what is typed.
const [narrowed, setNarrowed] = React.useState(typed)
// The values holding what is typed, then, when what is typed is none of them, the
// typed text itself as the last entry: a new value is chosen like any other.
const typedText = query.trim()
const entries = [
...(narrowed
? options.filter((o) => o.toLowerCase().includes(query.toLowerCase()))
: options
).map((value) => ({ value, fresh: false })),
...(typedText !== "" && !options.includes(typedText)
? [{ value: typedText, fresh: true }]
: []),
]
const [marked, setMarked] = React.useState(() =>
typed ? 0 : Math.max(0, options.indexOf(draft)),
)
// -1: nothing marked (the field typed empty, so Enter empties the cell).
const at = entries.length === 0 || marked < 0 ? -1 : marked % entries.length
const [place, setPlace] = React.useState<DOMRect | null>(null)
React.useLayoutEffect(() => {
const cell = input.current?.closest("td")
if (!cell) return
const read = () => setPlace(cell.getBoundingClientRect())
read()
window.addEventListener("scroll", read, true)
window.addEventListener("resize", read)
return () => {
window.removeEventListener("scroll", read, true)
window.removeEventListener("resize", read)
}
}, [])
const choice = (i: number) => `${id}-${i}`
return (
<>
<input
ref={input}
// biome-ignore lint/a11y/noAutofocus: an edit opens on the gesture that asked for it
autoFocus
role="combobox"
aria-label={label}
aria-expanded={entries.length > 0}
aria-controls={id}
aria-autocomplete="list"
aria-activedescendant={at >= 0 ? choice(at) : undefined}
value={query}
onChange={(e) => {
setQuery(e.target.value)
setNarrowed(true)
setMarked(e.target.value.trim() === "" ? -1 : 0)
}}
onFocus={(e) => {
const end = e.currentTarget.value.length
e.currentTarget.setSelectionRange(end, end)
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault()
const n = entries.length
const down = e.key === "ArrowDown"
if (n > 0)
setMarked(
at < 0 ? (down ? 0 : n - 1) : (at + (down ? 1 : -1) + n) % n,
)
return
}
if (e.key !== "Escape" && e.key !== "Enter" && e.key !== "Tab") return
e.preventDefault()
onDone(
e.key === "Escape" ? null : (entries[at]?.value ?? query),
e.key,
e.shiftKey,
)
}}
onBlur={() => onBlur(query)}
className={EDITOR}
/>
{place &&
createPortal(
// The frame stays whole; the list inside it scrolls, its cut edges
// fading where there is more (scroll-area).
<div
data-slot="table-choices"
className="fixed z-50 flex max-h-64 min-w-(--ag-choices-width) flex-col rounded-lg border border-border bg-popover/95 p-1 text-[13px] text-popover-foreground shadow-lg backdrop-blur-md"
style={
{
top: place.bottom + 4,
left: place.left,
"--ag-choices-width": `${place.width}px`,
} as React.CSSProperties
}
>
<ScrollArea id={id} role="listbox" aria-label={label}>
{entries.map(({ value, fresh }, i) => (
<div
key={fresh ? `\u0000${value}` : value}
id={choice(i)}
role="option"
aria-selected={i === at}
tabIndex={-1}
// A press takes the value without taking focus from the field first.
onPointerDown={(e) => {
e.preventDefault()
onDone(value, "Enter", false)
}}
className="flex h-7 cursor-pointer items-center gap-2 rounded-md px-2 text-foreground/80 aria-selected:bg-foreground/8 aria-selected:text-foreground"
>
{fresh ? (
<Plus className="size-3.5 shrink-0 opacity-50" />
) : (
<Check
className={cn(
"size-3.5 shrink-0",
value === draft && !typed ? "opacity-70" : "opacity-0",
)}
/>
)}
{fresh ? (
<span>
<span className="text-foreground/50">write </span>“{value}
”
</span>
) : (
value
)}
</div>
))}
</ScrollArea>
</div>,
document.body,
)}
</>
)
}
/** A yes/no cell: a check that is pressed to flip it. */
function Box({ on, label }: { on: boolean; label: string }) {
return (
<span
data-slot="table-check"
data-on={on || undefined}
className="grid size-3.5 cursor-pointer place-items-center rounded-[4px] border border-foreground/30 text-background data-[on]:border-foreground/80 data-[on]:bg-foreground/80"
>
{on && <Check className="size-2.5" strokeWidth={3.5} />}
<span className="sr-only">{label}</span>
</span>
)
}
// ── A CSV, viewed and edited: its text in, the text written back on every change,
// every change one step of its history (⌘Z, ⇧⌘Z).
interface Sheet {
csv: Omit<Csv, "header" | "rows">
columns: { id: string; label: string }[]
rows: { id: string; cells: Record<string, string> }[]
}
/** How many changes back ⌘Z reaches. */
const HISTORY = 200
export interface CsvTableProps
extends Pick<
TableProps<unknown>,
"density" | "interactive" | "className" | "label"
> {
/** The file's text, read once; give the table a new `key` to load another. */
text: string
/** The text after every change. Without it the table only reads. */
onChange?: (text: string) => void
}
const textOf = (s: Sheet) =>
toCsv({
...s.csv,
header: s.columns.map((c) => c.label),
rows: s.rows.map((r) => s.columns.map((c) => r.cells[c.id] ?? "")),
})
export function CsvTable({ text, onChange, ...props }: CsvTableProps) {
const seq = React.useRef(0)
const next = (prefix: string) => `${prefix}${seq.current++}`
const [state, setState] = React.useState<{
sheet: Sheet
past: Sheet[]
future: Sheet[]
}>(() => {
const { header, rows, ...csv } = parseCsv(text)
const columns = header.map((label) => ({ id: next("c"), label }))
return {
sheet: {
csv,
columns,
rows: rows.map((r) => ({
id: next("r"),
cells: Object.fromEntries(columns.map((c, i) => [c.id, r[i] ?? ""])),
})),
},
past: [],
future: [],
}
})
const { sheet } = state
const write = (s: Sheet) => {
setState({
sheet: s,
past: [...state.past, sheet].slice(-HISTORY),
future: [],
})
onChange?.(textOf(s))
}
const travel = (back: boolean) => {
const from = back ? state.past : state.future
const to = from.at(-1)
if (!to) return
setState(
back
? {
sheet: to,
past: state.past.slice(0, -1),
future: [...state.future, sheet],
}
: {
sheet: to,
past: [...state.past, sheet],
future: state.future.slice(0, -1),
},
)
onChange?.(textOf(to))
}
const columns = React.useMemo(
() =>
sheet.columns.map(
(c): TableColumn<Sheet["rows"][number]> => ({
id: c.id,
label: c.label,
text: (r) => r.cells[c.id] ?? "",
}),
),
[sheet.columns],
)
const blank = () => ({
id: next("r"),
cells: Object.fromEntries(sheet.columns.map((c) => [c.id, ""])),
})
const insertRow = (beside: string, after: boolean) => {
const i = sheet.rows.findIndex((r) => r.id === beside) + (after ? 1 : 0)
write({
...sheet,
rows: [...sheet.rows.slice(0, i), blank(), ...sheet.rows.slice(i)],
})
}
const insertColumn = (beside: string, after: boolean) => {
const i = sheet.columns.findIndex((c) => c.id === beside) + (after ? 1 : 0)
const column = {
id: next("c"),
label: `column ${sheet.columns.length + 1}`,
}
write({
...sheet,
columns: [
...sheet.columns.slice(0, i),
column,
...sheet.columns.slice(i),
],
})
}
const many = (ids: readonly string[]) =>
ids.length > 1 ? ` ${ids.length} rows` : ""
if (!onChange)
return (
<Table {...props} rows={sheet.rows} rowId={rowIdOf} columns={columns} />
)
return (
<Table
{...props}
rows={sheet.rows}
rowId={rowIdOf}
columns={columns}
onEdit={(edits) => {
const byRow = new Map<string, Record<string, string>>()
for (const e of edits)
byRow.set(e.row, { ...byRow.get(e.row), [e.column]: e.text })
write({
...sheet,
rows: sheet.rows.map((r) => {
const patch = byRow.get(r.id)
return patch ? { ...r, cells: { ...r.cells, ...patch } } : r
}),
})
}}
onUndo={() => travel(true)}
onRedo={() => travel(false)}
onRename={(column, label) =>
write({
...sheet,
columns: sheet.columns.map((c) =>
c.id === column ? { ...c, label } : c,
),
})
}
onMoveColumn={(column, to) => {
const moved = sheet.columns.filter((c) => c.id !== column)
moved.splice(
to,
0,
sheet.columns.find(
(c) => c.id === column,
) as Sheet["columns"][number],
)
write({ ...sheet, columns: moved })
}}
onAddRow={() => write({ ...sheet, rows: [...sheet.rows, blank()] })}
onAddColumn={() => insertColumn(sheet.columns.at(-1)?.id ?? "", true)}
actions={(ids) => [
...(ids.length === 1
? [
{
icon: <Rows3 />,
label: "insert a row above",
onSelect: () => insertRow(ids[0] as string, false),
},
{
icon: <Rows3 />,
label: "insert a row below",
onSelect: () => insertRow(ids[0] as string, true),
},
]
: []),
{
icon: <Copy />,
label: `duplicate${many(ids)}`,
onSelect: () =>
write({
...sheet,
rows: sheet.rows.flatMap((r) =>
ids.includes(r.id) ? [r, { ...r, id: next("r") }] : [r],
),
}),
},
"separator",
{
icon: <Trash2 />,
label: `delete${many(ids)}`,
hint: "mod+backspace",
destructive: true,
onSelect: () =>
write({
...sheet,
rows: sheet.rows.filter((r) => !ids.includes(r.id)),
}),
},
]}
columnActions={(column) => [
{
icon: <Plus />,
label: "insert a column left",
onSelect: () => insertColumn(column, false),
},
{
icon: <Plus />,
label: "insert a column right",
onSelect: () => insertColumn(column, true),
},
"separator",
{
icon: <Trash2 />,
label: "delete the column",
destructive: true,
disabled: sheet.columns.length === 1,
onSelect: () =>
write({
...sheet,
columns: sheet.columns.filter((c) => c.id !== column),
rows: sheet.rows.map((r) => {
const { [column]: _, ...cells } = r.cells
return { ...r, cells }
}),
}),
},
]}
/>
)
}
const rowIdOf = (r: { id: string }) => r.id// A TABLE'S DATA, framework-free: what kind a column is (read from its values, never
// declared), how two cells of a kind compare, and CSV in and out.
//
// KINDS ARE READ, NEVER DECLARED. A column is a number when every filled cell reads as
// one (a sign, a currency mark, thousands commas, a decimal point, a trailing %), a
// yes/no when every filled cell is one word of a single pair (yes/no, true/false, y/n),
// a date when every filled cell is an ISO date (a time may follow), else text. Empty
// cells say nothing either way; a column with no filled cell is text. The text itself
// is never rewritten: a number keeps its commas, a date its format, and the kind only
// decides alignment, figures, the sort and how a yes/no is flipped. A text column that
// repeats a few values (a status, a category) reads as choosing among them
// (`choicesOf`): its cells are offered those values when edited.
//
// CSV ROUND-TRIPS. `parseCsv` keeps what writing back needs (the delimiter it found,
// the line ending, whether the file ended in one), so `toCsv(parseCsv(text))` is the
// same text for any file that quotes only where it must. Rows shorter than the header
// are padded with empty cells; a row longer than the header screams, since there is no
// column to put the rest in.
export type ColumnKind = "text" | "number" | "boolean" | "date"
const NUMBER =
/^[-+]?[$€£¥]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?%?$|^[-+]?[$€£¥]?\.\d+%?$/
const DATE =
/^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/
/** The yes/no pairs a column may speak, true word first. */
const PAIRS: readonly (readonly [string, string])[] = [
["yes", "no"],
["true", "false"],
["y", "n"],
]
/** A cell's number, or null when it is not one (commas, currency and % ignored). */
export function numberOf(text: string): number | null {
const t = text.trim()
if (!NUMBER.test(t)) return null
return Number(t.replace(/[$€£¥,%]/g, ""))
}
/** A cell's instant in ms, or null when it is not an ISO date. */
export function dateOf(text: string): number | null {
const t = text.trim()
if (!DATE.test(t)) return null
const ms = Date.parse(t.length === 10 ? `${t}T00:00:00Z` : t)
return Number.isNaN(ms) ? null : ms
}
const pairOf = (text: string) => {
const t = text.trim().toLowerCase()
return PAIRS.findIndex(([y, n]) => t === y || t === n)
}
/** A yes/no cell's truth, or null when it is not one. */
export function truthOf(text: string): boolean | null {
const p = pairOf(text)
if (p < 0) return null
return text.trim().toLowerCase() === (PAIRS[p] as readonly string[])[0]
}
/** The other word of a yes/no cell's pair, in the cell's own case ("Yes" → "No"). An
* empty cell flips to `truthy`, the column's true word. */
export function flipped(text: string, truthy: string): string {
if (text.trim() === "") return truthy
const p = pairOf(text)
if (p < 0) throw new Error(`table-data: "${text}" is not a yes or a no`)
const [y, n] = PAIRS[p] as readonly [string, string]
const next = truthOf(text) ? n : y
const t = text.trim()
if (t === t.toUpperCase()) return next.toUpperCase()
if (t[0] === (t[0] as string).toUpperCase())
return (next[0] as string).toUpperCase() + next.slice(1)
return next
}
/** The kind a column's cells say it is. */
export function kindOf(values: readonly string[]): ColumnKind {
const filled = values.filter((v) => v.trim() !== "")
if (filled.length === 0) return "text"
if (filled.every((v) => numberOf(v) !== null)) return "number"
const pair = pairOf(filled[0] as string)
if (pair >= 0 && filled.every((v) => pairOf(v) === pair)) return "boolean"
if (filled.every((v) => dateOf(v) !== null)) return "date"
return "text"
}
/** The most values a column offers as choices. */
const CHOICES_MAX = 8
/** The values a text column chooses from, when its cells say it chooses: a few values
* (two to eight), filled at least four times, each used one and a half times on
* average (a status, a category; names and titles repeat too rarely). Sorted as text;
* null when the column reads as free text. */
export function choicesOf(values: readonly string[]): string[] | null {
const filled = values.map((v) => v.trim()).filter((v) => v !== "")
const distinct = [...new Set(filled)]
if (
filled.length < 4 ||
distinct.length < 2 ||
distinct.length > CHOICES_MAX ||
distinct.length > filled.length / 1.5
)
return null
return distinct.sort(collator.compare)
}
/** The true word a yes/no column writes into an empty cell: the case its cells use. */
export function truthyOf(values: readonly string[]): string {
const sample = values.find((v) => pairOf(v) >= 0)
if (!sample) return "yes"
return truthOf(sample) ? sample.trim() : flipped(sample, "yes")
}
const collator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: "base",
})
/** How two filled cells of a kind compare, ascending. */
export function compare(kind: ColumnKind, a: string, b: string): number {
switch (kind) {
case "number":
return (numberOf(a) as number) - (numberOf(b) as number)
case "date":
return (dateOf(a) as number) - (dateOf(b) as number)
case "boolean":
return Number(truthOf(b)) - Number(truthOf(a))
case "text":
return collator.compare(a, b)
}
}
export type SortDirection = "asc" | "desc"
/** `ids` sorted by their cells, empty cells last in either direction; equal cells keep
* their order. */
export function sorted(
ids: readonly string[],
textOf: (id: string) => string,
kind: ColumnKind,
direction: SortDirection,
): string[] {
const sign = direction === "asc" ? 1 : -1
return ids
.map((id, i) => ({ id, i, text: textOf(id) }))
.sort((a, b) => {
const ea = a.text.trim() === ""
const eb = b.text.trim() === ""
if (ea || eb) return ea === eb ? a.i - b.i : ea ? 1 : -1
return sign * compare(kind, a.text, b.text) || a.i - b.i
})
.map((r) => r.id)
}
// ── Addresses, a spreadsheet's: columns lettered A, B, … Z, AA, rows numbered from 1
// in the order shown. "B9" is a cell, "9" a row, "B" a column, "B2:D5" a block.
/** A column's letters by its place (0 → A, 26 → AA). */
export function columnName(at: number): string {
if (!Number.isInteger(at) || at < 0)
throw new Error(`table-data: no column at ${at}`)
let name = ""
for (let n = at + 1; n > 0; n = Math.floor((n - 1) / 26))
name = String.fromCharCode(65 + ((n - 1) % 26)) + name
return name
}
/** A column's place by its letters (A → 0), case ignored. */
export function columnAt(name: string): number {
if (!/^[a-z]+$/i.test(name))
throw new Error(`table-data: "${name}" is not a column`)
return (
[...name.toUpperCase()].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) -
1
)
}
/** One end of an address: a column, a row (both 0-based), or both. */
export interface Place {
column?: number
row?: number
}
/** What an address names, or null when it is not one: a cell, a row, a column, or a
* block from one to the other. */
export function parseAddress(text: string): { from: Place; to: Place } | null {
const place = (t: string): Place | null => {
const m = /^([a-z]*)(\d*)$/i.exec(t.trim())
if (!m || (!m[1] && !m[2])) return null
const [, letters, digits] = m
if (digits && Number(digits) < 1) return null
return {
...(letters ? { column: columnAt(letters) } : {}),
...(digits ? { row: Number(digits) - 1 } : {}),
}
}
const [a, b, ...rest] = text.split(":")
if (rest.length > 0 || a === undefined) return null
const from = place(a)
const to = b === undefined ? from : place(b)
return from && to ? { from, to } : null
}
// ── CSV
export interface Csv {
header: string[]
rows: string[][]
delimiter: "," | ";" | "\t"
newline: "\n" | "\r\n"
/** The text ended in a line break. */
trailing: boolean
}
/** The delimiter the first line uses most, outside quotes. */
function delimiterOf(text: string): Csv["delimiter"] {
const counts = { ",": 0, ";": 0, "\t": 0 }
let quoted = false
for (const c of text) {
if (c === '"') quoted = !quoted
else if (!quoted && (c === "\n" || c === "\r")) break
else if (!quoted && c in counts) counts[c as keyof typeof counts]++
}
return counts[";"] > counts[","] && counts[";"] >= counts["\t"]
? ";"
: counts["\t"] > counts[","]
? "\t"
: ","
}
/** RFC 4180 text into a header and rows. The first record is the header. */
export function parseCsv(text: string): Csv {
const delimiter = delimiterOf(text)
const newline = text.includes("\r\n") ? "\r\n" : "\n"
const records: string[][] = []
let record: string[] = []
let field = ""
let quoted = false
let i = 0
while (i < text.length) {
const c = text[i] as string
if (quoted) {
if (c === '"' && text[i + 1] === '"') {
field += '"'
i += 2
continue
}
if (c === '"') quoted = false
else field += c
i++
continue
}
if (c === '"' && field === "") quoted = true
else if (c === delimiter) {
record.push(field)
field = ""
} else if (c === "\n" || c === "\r") {
record.push(field)
records.push(record)
record = []
field = ""
if (c === "\r" && text[i + 1] === "\n") i++
} else field += c
i++
}
if (quoted) throw new Error("table-data: a quote is never closed")
const trailing = /\r?\n$/.test(text)
if (!trailing && (field !== "" || record.length > 0)) {
record.push(field)
records.push(record)
}
const header = records[0] ?? []
const rows = records.slice(1).map((r, n) => {
if (r.length > header.length)
throw new Error(
`table-data: row ${n + 1} has ${r.length} cells, the header ${header.length}`,
)
return [...r, ...Array<string>(header.length - r.length).fill("")]
})
return { header, rows, delimiter, newline, trailing }
}
const fieldOf = (text: string, delimiter: string) =>
text.includes('"') ||
text.includes(delimiter) ||
text.includes("\n") ||
text.includes("\r")
? `"${text.replace(/"/g, '""')}"`
: text
/** A header and rows back into the text, in the file's own delimiter and line ending,
* quoting only where a field must be quoted. */
export function toCsv(csv: Csv): string {
const line = (r: readonly string[]) =>
r.map((f) => fieldOf(f, csv.delimiter)).join(csv.delimiter)
const text = [csv.header, ...csv.rows].map(line).join(csv.newline)
return csv.trailing ? text + csv.newline : text
}