Components
Data grid
Many records at once, a row each and a column for each field, moved through, selected and edited in place.
MarkdownA data grid shows many records at once, a row for each and a column for each of their fields, and lets people move through them, select them and edit them in place. It is where data is browsed and corrected in bulk.
A hundred thousand products
Scroll, or press Command-End. Type over a price — 12*3, or 1,250.50 — and press Return; open a status; press Space on Featured. Select a block and copy it into a spreadsheet, or paste one back. Press the secondary button, or Shift-F10, for the menu; press the up arrow in the first row to reach a header, then Return to sort and Alt with an arrow to move the column. On a phone, drag the selection's handles, rest a finger on a cell for the menu, and on a header for the column's.
100,000 rows · 0 edits
Code
"use client";
import { DataGrid } from "@publictheta/ui/components/data-grid/data-grid.tsx";
import { schema } from "@publictheta/ui/primitives/schema.ts";
import { useState } from "react";
const product = schema({
kind: "object",
fields: [
{ key: "name", schema: { kind: "text", label: "Name" } },
{
key: "price",
schema: {
kind: "number",
label: "Price",
minimum: 0,
precision: 2,
format: "currency",
currency: "USD",
},
},
{ key: "stock", schema: { kind: "number", label: "In stock", minimum: 0, precision: 0 } },
{
key: "status",
schema: {
kind: "choice",
label: "Status",
options: [
{ value: "draft", label: "Draft" },
{ value: "review", label: "In review" },
{ value: "live", label: "Live" },
],
},
},
{ key: "released", schema: { kind: "date", label: "Released", optional: true } },
{ key: "featured", schema: { kind: "boolean", label: "Featured" } },
],
});
const THINGS = ["Teapot", "Kettle", "Mug", "Saucer", "Tray", "Jug", "Bowl", "Plate", "Strainer", "Caddy"];
const FINISHES = ["White", "Celadon", "Cobalt", "Slate", "Oatmeal", "Rust", "Ink"];
const STATUSES = ["draft", "review", "live"];
/** The same rows on every visit: derived from the row number alone. */
function make(count: number): Record<string, unknown>[] {
return Array.from({ length: count }, (_, index) => {
const mixed = (index * 2654435761) % 4294967296;
const day = 1 + (mixed % 28);
const month = 1 + ((mixed >>> 5) % 12);
return {
name: `${FINISHES[mixed % FINISHES.length]} ${THINGS[index % THINGS.length]} ${Math.floor(index / THINGS.length) + 1}`,
price: 8 + (mixed % 9000) / 100,
stock: mixed % 400,
status: STATUSES[(mixed >>> 3) % 3],
released:
mixed % 5 === 0 ? null : `2026-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
featured: mixed % 7 === 0,
};
});
}
export function ProductGrid({ count, height }: { count: number; height: number }) {
const [rows, setRows] = useState(() => make(count));
const [edits, setEdits] = useState(0);
return (
<div className="flex w-full flex-col gap-3">
<DataGrid
aria-label="Products"
schema={product}
locale="en-US"
rows={rows}
onRowsChange={(next) => {
setRows(next);
setEdits((n) => n + 1);
}}
columnWidth={148}
style={{ height }}
/>
<p aria-live="polite" className="text-sm text-fg-muted">
{new Intl.NumberFormat("en-US").format(rows.length)} rows · {edits} {edits === 1 ? "edit" : "edits"}
</p>
</div>
);
}
export default function Products() {
return <ProductGrid count={100_000} height={360} />;
}Order lines, computed and locked
Amount is price × quantity, and Total the amount less the discount: change a quantity and both follow, as one change. The teapot's total is held at what was agreed — change its quantity and the change is refused, kept in the cell with why; Escape puts it aside. Paste 32 and 3 over its price and quantity and the total stays, so that is allowed. Type over an amount and it is frozen there. Select the prices and paste a column of new ones: what the locks allow is done, and what they refuse is said. A secondary click, Shift-F10 or a finger's rest opens the menu, to hold, freeze, unfreeze or unlock a cell or a range; Command-Z undoes each step, with every value and lock it changed.
- Order total
- $240.60
- Locks
- Teapot · Total held
Code
"use client";
import { DataGrid } from "@publictheta/ui/components/data-grid/data-grid.tsx";
import { deriveRecord, derivationsOf, lockKey } from "@publictheta/ui/primitives/derived.ts";
import type { Lock } from "@publictheta/ui/primitives/derived.ts";
import { schema } from "@publictheta/ui/primitives/schema.ts";
import { useState } from "react";
const money = { kind: "number", format: "currency", currency: "USD", minimum: 0 } as const;
const line = schema({
kind: "object",
fields: [
{ key: "item", schema: { kind: "text", label: "Item" } },
{ key: "price", schema: { ...money, label: "Price" } },
{ key: "quantity", schema: { kind: "number", label: "Quantity", minimum: 0, precision: 0 } },
{
key: "discount",
schema: {
kind: "number",
label: "Discount",
format: "percent",
optional: true,
minimum: 0,
maximum: 1,
},
},
{ key: "amount", schema: { ...money, label: "Amount", formula: "price * quantity" } },
{
key: "total",
schema: { ...money, label: "Total", formula: "round(amount * (1 - coalesce(discount, 0)), 2)" },
},
],
});
type Row = Readonly<Record<string, unknown>>;
const { plan } = derivationsOf(line);
/** The lines as they are first shown, with what each computes made right. */
const LINES: readonly Row[] = [
{ item: "Teapot", price: 48, quantity: 2, discount: 0.1 },
{ item: "Cup", price: 12, quantity: 6, discount: null },
{ item: "Saucer", price: 8, quantity: 6, discount: null },
{ item: "Tray", price: 36, quantity: 1, discount: 0.05 },
].map((row) => deriveRecord(plan, row));
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const LABELS = new Map<string, string>(
line.fields.map((field) => [field.key, field.schema.label ?? field.key]),
);
export default function OrderLines() {
const [rows, setRows] = useState<readonly Row[]>(LINES);
// The teapot's total is agreed with the customer.
const [locks, setLocks] = useState<ReadonlyMap<string, Lock>>(
() => new Map([[lockKey("0", "total"), "held"]]),
);
const sum = rows.reduce((total, row) => total + (typeof row.total === "number" ? row.total : 0), 0);
// Locks are kept under each record's place: say them by the record's item.
const said = [...locks].map(([key, lock]) => {
const [record = "", field = ""] = key.split("\u001f");
const item = rows[Number(record)]?.item;
return `${typeof item === "string" && item !== "" ? item : `Line ${Number(record) + 1}`} · ${LABELS.get(field) ?? field} ${lock}`;
});
return (
<div className="flex w-full flex-col gap-3">
<DataGrid
aria-label="Order lines"
schema={line}
locale="en-US"
rows={rows}
onRowsChange={setRows}
locks={locks}
onLocksChange={setLocks}
columnWidth={112}
defaultColumnWidths={{ item: 128 }}
style={{ height: 260 }}
/>
<dl className="grid gap-x-4 gap-y-1 text-sm [grid-template-columns:auto_minmax(0,1fr)]">
<dt className="text-fg-muted">Order total</dt>
<dd className="text-fg-strong tabular-nums">{usd.format(sum)}</dd>
<dt className="text-fg-muted">Locks</dt>
<dd className="text-fg-strong">{said.length === 0 ? "None" : said.join(", ")}</dd>
</dl>
</div>
);
}When to use
Use a data grid when people compare records, scan a column, or change many values. For one record with many fields, an object editor gives each field room. For a list to choose from, a listbox is lighter.
Best practices
Show each value as its kind. Numbers aligned to compare, in the reader's locale; dates as they are written; a choice by its label; a yes-or-no as a checkbox. The columns and their kinds come from the records' schema.
Stay immediate, whatever the size. Draw the rows in view and no others, so that a hundred thousand rows scroll and respond as a hundred do. Tell assistive technology the true number of rows and each row's place among them. A browser will not lay out an element more than a few million pixels tall; past that, let the scroll position stand for a proportion of the way through, so that the last row can still be reached.
Edit in place. Simply starting to type replaces the cell's contents; Return, F2 or a double click opens the cell to work within its text, with the caret at its end. Return commits and moves down, Tab commits and moves right — on to the next row from the end of one — and Escape abandons the edit. After Tabs across a row, Return goes to the next row in the column the Tabs began in, so that records can be entered one after another.
Let the arrows do what the edit calls for. In an edit begun by typing over a cell, an arrow key commits and moves on, as in a spreadsheet. In a cell opened to work within its text, the arrows move the caret. F2 goes from the one to the other. An arrow never changes a number or a date in a cell: stepping belongs to a field on its own.
Type straight into the cell. Focus is on the active cell's own control, so that what is typed is typed into it directly: an input method composes from its first key, and characters reached with Option or AltGr begin an edit like any other.
Read what is typed as its kind. The cell reads what is typed as the field for its kind would — arithmetic in a number, a date in whatever form comes to hand — and a choice is chosen by typing the start of its name.
Never lose what was typed. Text that cannot be read as a value keeps the cell in editing, marked, and says why. Return, Tab, an arrow and a press on another cell all leave it open; only Escape abandons it. If focus leaves the grid, the text stays as typed for the person to come back to.
Mark what the schema does not allow. A cell holding a value its field does not allow — nothing, where something is required; a number outside its range — is marked and says why. The grid does not refuse to hold it: whether such records may be saved is for the application to decide.
Mark what the owner knows is wrong, too. Some problems no schema can state — a key used twice, a name the server refused. Let the application say where they are, by record and field, and mark those cells and say why exactly as the cells the schema does not allow are marked. A problem the schema states is the one a cell says first.
Show what cannot be changed, and pass over it. A column whose field is read-only is shown, selected and copied like any other; it says that it is read-only; typing does not edit it, and clearing, pasting and filling pass over it and change the columns beside it.
Keep what is computed right, and show that it is. A column whose formula computes it follows what it reads: every change the grid makes — typing, pasting, filling, clearing, adding a record, an undo — goes out with every value it moves, as one change, and only what the change reaches is computed again. The column's header says it is computed and as what; a formula that cannot be used is said on its header, which is marked. Records are shown as they are given: what the application gives, it has computed, and the grid makes right what its own edits reach.
Let cells be locked, and say which are. Typing over a computed cell freezes it at what was typed — but a paste or a fill that writes what the cell would come to anyway leaves it following. The cells' menu offers, on a computed cell, Hold value and Freeze; on a frozen one, Hold value and Unfreeze, which computes it again; on a held one, Unlock — and on a range the same, for every cell it applies to, as one step to undo. Where the application works with locks every cell offers them, and elsewhere only the computed ones. A held cell is shown, selected and copied, and not edited, and says why. An edit that would move a held value is not made: the cell keeps what was typed, marked, with the reason — "Amount is held; this would change it." — said aloud; Escape puts it aside, and Return asks for it again, which a lock taken off since lets through. A paste, a fill or a clear does what it may, leaves what locks refuse, and says once how many cells were refused and whose locks refused them.
Know a record by what it is, for its locks. A lock belongs to a record, not to a place in the table. Sorting moves no record, and the grid moves locks with the records it adds and removes itself; but where the application adds, removes or reorders records on its own, give each record a key that stays with it — its id — or a lock stays at a place another record has come to.
Change a yes-or-no directly. Space, or a click on the checkbox, changes it; there is nothing to type.
Fill a selection at once. With several cells selected, Command-Return gives what was typed to every one of them, each reading it as its own column does.
Fill from what is beside. Under a mouse, the selection has a small handle at its end corner: dragged, it fills the cells it passes over from the selection — repeating it, or continuing it where numbers or dates leave one way to continue them and no other; see Filling. Command-D fills the selection down from its first row, and Command-R across from its first column. Fill series, in the menu, continues what the values at the selection's start make through the rest of it, as the handle would: the same series for a pointer that has no handle. A fill is one change; it leaves read-only cells as they were and says how many it left.
Offer a menu of what can be done. A menu on the selection — cut, copy, paste, clear, fill down, across and as a series, insert rows above, delete rows — puts every command within reach of a pointer with no keyboard, and names each for assistive technology. It opens on a secondary press, on Shift with F10 or the Menu key against the active cell, and under a finger that rests. What cannot be done where it was asked stays in the menu and cannot be chosen, so that the menu is the same wherever it opens. Deleting rows from the menu removes every row the selection is in, and the item says how many: the menu names what it will do, where a key has to be guarded. While a cell is being edited, the menu asked for is its field's own. A page may copy to the clipboard only when the browser allows, and read from it more rarely still: where it does not, change nothing and say that the keyboard's own copy and paste will.
Work as a spreadsheet does on the clipboard. Copy, cut and paste exchange cells with other applications, as text and as a table; see Values as text. What a cut clears is exactly what it copied. A block pasted over a selection it divides evenly is repeated across it. A clipboard that holds no text changes nothing. Say what a paste did: how many cells it filled, and how many it left because their text could not be read.
Let each column be as wide as its contents ask. A column's edge, in its header, is dragged to resize it, and pressed twice to fit the column to its name and to what its cells hold. A column is never made too narrow to press. From the keyboard, Alt and Shift with an arrow to the side move the edge of the active cell's column, or of the column whose header has focus. Right to left, the directions are mirrored.
Reach the headers from the cells. The up arrow from a column's first cell goes to the column's header, and the down arrow comes back; along the headers, the arrows, Home and End move from one to the next, and Escape returns to the cell that was left. A spreadsheet's own headers cannot be reached by key, and its sorting and sizing live in menus elsewhere; a grid whose headers sort, resize and move has to let the keyboard and assistive technology get to them, which is what the grid pattern of the ARIA practices asks. Only the plain up arrow leaves the cells — with Shift it extends the selection, with the command key it stops at the first row — so nothing a spreadsheet's user does by habit lands there by surprise. A header takes focus on its own control, the button that sorts: Return or Space sorts, Alt and Shift with an arrow to the side move the column's edge, and the Menu key opens the column's menu — sort either way, fit to its contents, move. Tab from a header goes on from the grid, which stays one stop. A grid with no rows is still that stop, and an arrow key there goes to its first header: the columns can be sorted and moved before there is anything in them.
Let the columns be put in any order. A header dragged past the middle of its neighbour takes its column there, with a line showing where it would land; held near an edge of the view, the columns scroll to it, as far in a second on a slow machine as on a fast one; on a header, Alt with an arrow to the side moves the column one place and says where it now is; see Reordering. The order is the viewer's, kept apart from the records and their schema. The active cell and the selection go with their columns, and what was done before the move is still undone in the cells it was done to.
Sort by any column, without moving a record. Each column's header has a button that sorts by it — ascending, then descending, then not at all — and shows which way. The records stay as they are kept; a row does not jump as it is edited; see Sorting a view.
Number the rows, and let a row be selected by its number. A header at the start of each row shows its place, selects the row when pressed — with Shift, the rows from the active cell's to it — and stays in view as the columns scroll. The corner where the headers meet selects everything.
End in a row that is not yet a record. Where records can be added, a row waits after the last one. The arrows step into it; typing in it makes a record, and another row waits after that. Nothing in it is marked as missing. A pasted block that runs past the last record makes records to hold the rest. Moving to an edge, and selecting a column or everything, stop at the last record.
Remove rows only when rows are what is selected. Delete empties cells. With the command key, Backspace removes the rows that are selected whole — and no row of which only some cells are selected — and says how many.
Make every change undoable. Editing, clearing, cutting, pasting, filling, adding records and removing them, locking and unlocking can each be taken back with one undo — with every value it computed and every lock it made — a removed record comes back as the very record, where it was; see Undo and redo and Changes to records.
Change only what was changed. An edit to a cell alters that row and no other, so everything else that shows the data stays as it was. Where the rows are too many to copy on each change, report an edit as what it changed: the cells, the records added, the records removed.
Hold together when the data changes. Rows may arrive late, be replaced, or grow fewer while the grid is in use. The selection stays within the rows there are; an edit ends when the record it was editing is gone.
Platform considerations
Pointer and keyboard. See Grid navigation and Cell selection.
Pointer. A drag selects, and scrolls the grid when it passes an edge. A secondary press selects the cell under it unless that cell is already selected — and on a row's header, the row — so that the menu opened there acts on what it appears over. The handle that fills, and a header's drag, are a mouse's.
Touch. Rows are at least 44 CSS pixels tall. A tap makes a cell active; a tap on the active cell edits it. A cell holds no field until it is edited, so that selecting one does not bring up the keyboard. A finger has no Shift and its drag scrolls, so the selection has two handles, at its start corner and its end, each at least 44 pixels across: dragging one redraws the selection from the other, taking in the next row once the finger is half a row into it, and scrolling the grid when the finger comes within reach of an edge — within it, since a finger cannot always go beyond. A handle lies over the headers where its corner is at their edge, so that the whole of it can be pressed, and is hidden once its corner scrolls beneath them; tapped and not dragged, it is not in the way of what is under it. A finger that rests on a cell selects it and opens the menu, as does a tap on the selection anywhere but its active cell; on a phone the menu is a sheet. There is no handle to fill by — it would lie under the selection's own — and no dragging a header, which scrolls: filling, a series too, is in the menu, and a finger that rests on a column's header opens the column's menu, to sort, fit and move it.
Right to left. Columns run from the right, and the arrows, Home and End follow them.
Assistive technology. The grid states its name, its numbers of rows and columns, the place of each row and column as it is shown — whatever is in view, however it is sorted and wherever its columns have been moved — which column it is sorted by and which way, which cells are selected, and which cells hold what is not allowed. A cell is described by its lock and by what it is computed as, and a column's header by what it is computed as or by why its formula cannot be used. Focus is on the active cell, or on the header reached from it. The handles a pointer drags are not offered to it: the keyboard and the menus do all they do. What cannot be seen happening — a paste, a fill, a column moved, an undo, text that could not be read, a clipboard that could not be reached, a change a lock refused — is said.
Behaviour
Semantics
Is a grid with its name, counting every row and column, the header included
const screen = await render(<Grid count={8} />);
// Eight records, the header above them, and the row that waits to become a ninth.
await expect.element(grid(screen)).toHaveAttribute("aria-rowcount", "10");
// Five fields, and the row headers before them.
await expect.element(grid(screen)).toHaveAttribute("aria-colcount", "6");Its columns are the fields of the schema, named by their labels
const screen = await render(<Grid />);
const headers = screen
.getByRole("columnheader")
.elements()
.map((element) => element.textContent);
expect(headers).toEqual(["Select all", "Name", "Price", "Status", "Released", "Featured"]);Each cell shows its value as its kind is shown
const screen = await render(<Grid />);
await expect.element(grid(screen)).toBeVisible();
expect(cellText(0, 0)).toBe("Item 1");
expect(cellText(1, 1)).toBe("20");
expect(cellText(1, 2)).toBe("Live");
expect(cellText(0, 3)).toBe("Oct 13, 2026");
expect(cellText(1, 3)).toBe("");
expect(cellElement(0, 4).querySelector<HTMLInputElement>("input")!.checked).toBe(true);Has no accessibility violations
const screen = await render(<Grid />);
await expectAccessible(screen.container);Rows in view
Of a hundred thousand rows, only those in view are drawn
const screen = await render(<Grid count={100_000} />);
await expect.element(grid(screen)).toHaveAttribute("aria-rowcount", "100002");
await expect.poll(() => screen.getByRole("row").elements().length).toBeGreaterThan(5);
expect(screen.getByRole("row").elements().length).toBeLessThan(40);Scrolling draws the rows that come into view, each stating its place among all
const screen = await render(<Grid count={100_000} />);
gridElement(screen).scrollTop = 50_000 * ROW;
await expect.poll(() => cellElement(50_000, 0)?.textContent).toBe("Item 50001");
expect(cellElement(50_000, 0).parentElement!.getAttribute("aria-rowindex")).toBe("50002");The active cell stays drawn when it is scrolled out of view
const screen = await render(<Grid count={100_000} />);
await click(2, 1);
gridElement(screen).scrollTop = 80_000 * ROW;
// What it says when it is not there tells where the grid was instead.
const element = gridElement(screen);
await expect
.poll(
() =>
cellElement(80_000, 0)?.textContent ??
`no row 80001: scrolled to ${element.scrollTop} of ${element.scrollHeight}, active ${active()}, selected ${selected().length}`,
// WebKit as a phone, on a busy machine, draws nothing for ten seconds after a mouse's click:
// no frame, and so no scroll heard. It is waited out.
{ timeout: 30_000 },
)
.toBe("Item 80001");
expect(active()).toBe("2,1");Rows that change
A list too long for a browser to lay out is still scrolled, and moved, to its end
const row = make(1)[0]!;
const rows = Array.from<Product>({ length: 2_000_000 }).fill(row);
const screen = await render(
<DataGrid
aria-label="Products"
schema={product}
locale="en-US"
rows={rows}
columnWidth={120}
style={{ height: 300, width: 640 }}
/>,
);
const element = gridElement(screen);
await expect.element(grid(screen)).toBeVisible();
expect(element.scrollHeight).toBeLessThan(6_000_000);
element.scrollTop = element.scrollHeight;
await expect.poll(() => cellElement(1_999_999, 0)?.getBoundingClientRect().bottom).toBeDefined();
const view = element.getBoundingClientRect();
expect(cellElement(1_999_999, 0).getBoundingClientRect().bottom).toBeLessThanOrEqual(view.bottom);
expect(cellElement(1_999_999, 0).getBoundingClientRect().top).toBeGreaterThan(view.top);
element.scrollTop = 0;
await click(0, 0);
await press("{PageDown}{PageDown}");
const moved = Number(active().split(",")[0]);
expect(moved).toBeGreaterThan(4);
// Exactly at the foot of the view, where a move down brings it: not a row's fraction off,
// as it would be if its place were read back from a scroll position that stands for many.
const foot = view.top + element.clientTop + element.clientHeight;
await expect
.poll(() => Math.abs(cellElement(moved, 0).getBoundingClientRect().bottom - foot))
.toBeLessThan(0.5);A grid whose rows arrive after it does has its first cell active once they do
function Late() {
const [rows, setRows] = useState<Product[]>([]);
return (
<>
<button type="button" onClick={() => setRows(make(3))}>
Load
</button>
<DataGrid
aria-label="Products"
schema={product}
rows={rows}
style={{ height: 300, width: 640 }}
/>
</>
);
}
const screen = await render(<Late />);
await userEvent.click(screen.getByRole("button", { name: "Load" }));
await expect.poll(() => active()).toBe("0,0");When the row being edited goes away, the edit is over and the keys are the grid's again
const screen = await render(<Grid />);
await click(5, 0);
await press("Kettle");
(screen.getByRole("button", { name: "Load others" }).element() as HTMLElement).click();
// Held within the rows there now are: two records, and the row after them.
await expect.poll(() => active()).toBe("2,0");
expect(document.querySelector("[data-editing]")).toBeNull();
await click(1, 0);
await press("{ArrowUp}");
expect(active()).toBe("0,0");
expect(cellText(0, 0)).toBe("Item 1");
// And when another record takes its place in the same row.
await press("Kettle");
(screen.getByRole("button", { name: "Load others" }).element() as HTMLElement).click();
await expect.poll(() => cellText(0, 0)).toBe("Item 1");
expect(document.querySelector("[data-editing]")).toBeNull();A row that is missing shows as an empty one
const rows = [make(1)[0]!, undefined as unknown as Product];
const screen = await render(
<DataGrid aria-label="Products" schema={product} rows={rows} style={{ height: 300, width: 640 }} />,
);
await expect.element(grid(screen)).toBeVisible();
expect(cellText(1, 0)).toBe("");Moving
A click makes a cell the active one
await render(<Grid />);
await click(3, 1);
expect(active()).toBe("3,1");
expect(selected()).toEqual(["3,1"]);Arrow keys move the active cell
await render(<Grid />);
await click(3, 1);
await press("{ArrowDown}{ArrowRight}{ArrowRight}{ArrowUp}{ArrowLeft}");
expect(active()).toBe("3,2");The command key with an arrow, and Home and End, go to the edges
await render(<Grid />);
await click(3, 1);
await press(`{${COMMAND}>}{ArrowDown}{/${COMMAND}}`);
expect(active()).toBe("7,1");
await press("{End}");
expect(active()).toBe("7,4");
await press(`{${COMMAND}>}{Home}{/${COMMAND}}`);
expect(active()).toBe("0,0");Moving to a cell out of view brings it into view
const screen = await render(<Grid count={100_000} />);
await click(0, 0);
await press(`{${COMMAND}>}{End}{/${COMMAND}}`);
await expect.poll(() => active()).toBe("99999,4");
const view = gridElement(screen).getBoundingClientRect();
await expect
.poll(() => cellElement(99_999, 4).getBoundingClientRect().bottom)
.toBeLessThanOrEqual(view.bottom);
expect(cellElement(99_999, 4).getBoundingClientRect().right).toBeLessThanOrEqual(view.right);The grid is one stop in the Tab sequence: Tab enters at the active cell, and Tab again leaves
const screen = await render(
<>
<Grid />
<input aria-label="After" />
</>,
);
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
await userEvent.tab();
await expect.poll(() => cellElement(0, 0).contains(document.activeElement)).toBe(true);
await userEvent.tab();
await expect.element(screen.getByRole("textbox", { name: "After" })).toHaveFocus();Focus is on the active cell's own control, and follows the active cellChromium, Firefox, WebKit
Not yet written.
Right to left
Moving to a column out of view brings it into view
const screen = await render(
<div dir="rtl">
<Grid style={{ height: 300, width: 300 }} />
</div>,
);
await click(1, 0);
await press("{End}");
expect(active()).toBe("1,4");
const view = gridElement(screen).getBoundingClientRect();
await expect
.poll(() => cellElement(1, 4).getBoundingClientRect().left)
.toBeGreaterThanOrEqual(view.left);
await press("{Home}");
await expect
.poll(() => cellElement(1, 0).getBoundingClientRect().right)
.toBeLessThanOrEqual(view.right);Selecting
Shift with an arrow extends a rectangle from the active cell
await render(<Grid />);
await click(1, 1);
await press("{Shift>}{ArrowDown}{ArrowRight}{/Shift}");
expect(selected()).toEqual(["1,1", "1,2", "2,1", "2,2"]);
expect(active()).toBe("1,1");Shift-click selects the rectangle from the active cell to the cell clicked
await render(<Grid />);
await click(0, 0);
await click(2, 1, ["Shift"]);
expect(selected()).toEqual(["0,0", "0,1", "1,0", "1,1", "2,0", "2,1"]);Command-click adds a cell, which becomes the active oneChromium, Firefox, WebKit
Not yet written.
Pressing a cell and dragging to another selects the rectangle between themChromium, Firefox, WebKit
Not yet written.
A press on a column's header selects the column
const screen = await render(<Grid count={5} />);
await userEvent.click(screen.getByRole("columnheader", { name: "Price" }));
expect(selected()).toEqual(["0,1", "1,1", "2,1", "3,1", "4,1"]);Command-A selects every cell and leaves the active cell where it was
await render(<Grid count={3} />);
await click(1, 1);
await press(`{${COMMAND}>}a{/${COMMAND}}`);
expect(selected()).toHaveLength(15);
expect(active()).toBe("1,1");Escape leaves only the active cell selected
await render(<Grid />);
await click(1, 1);
await press("{Shift>}{ArrowDown}{/Shift}{Escape}");
expect(selected()).toEqual(["1,1"]);Dragging past the edge of the view scrolls it, selecting as it goesChromium, Firefox, WebKit
Not yet written.
A frame that comes late scrolls as far as its lateness, to a limit: a slow machine scrolls no slowerChromium, Firefox, WebKit
Not yet written.
Shift with Space selects the active cell's row, and Control with Space its column
await render(<Grid count={3} />);
await click(1, 1);
await press("{Shift>} {/Shift}");
expect(selected()).toEqual(["1,0", "1,1", "1,2", "1,3", "1,4"]);
expect(active()).toBe("1,1");
expect(cellText(1, 1)).toBe("20");
await press("{Escape}{Control>} {/Control}");
expect(selected()).toEqual(["0,1", "1,1", "2,1"]);
expect(active()).toBe("1,1");
// Shift with an arrow along them takes in the next whole column, and the next whole row.
await press("{Shift>}{ArrowRight}{/Shift}");
expect(selected()).toEqual(["0,1", "0,2", "1,1", "1,2", "2,1", "2,2"]);
await press("{Escape}{Shift>} {ArrowUp}{/Shift}");
expect(selected()).toHaveLength(10);
expect(active()).toBe("1,1");A secondary press outside the selection selects the cell it lands on; within it, the selection is keptChromium, Firefox, WebKit
Not yet written.
With Shift, a press on a column's header selects from the active cell's column to it
const screen = await render(<Grid count={2} />);
await click(1, 1);
await userEvent.click(screen.getByRole("columnheader", { name: "Released" }), { modifiers: ["Shift"] });
expect(selected()).toEqual(["0,1", "0,2", "0,3", "1,1", "1,2", "1,3"]);
expect(active()).toBe("1,1");A press on a checkbox with Shift or the command key selects, and leaves the box as it wasChromium, Firefox, WebKit
Not yet written.
Editing
Enter opens the active cell for editing, with the caret at the end of its text
await render(<Grid />);
await click(1, 0);
expect(editor()).toBeNull();
await press("{Enter}");
await expect.poll(() => document.activeElement).toBe(editor());
expect([editor()!.selectionStart, editor()!.selectionEnd]).toEqual([6, 6]);
await press("!{Enter}");
expect(cellText(1, 0)).toBe("Item 2!");Typing begins editing, and what was typed replaces the cell's text
const onChange = vi.fn();
await render(<Grid onChange={onChange} />);
await click(1, 1);
await press("75{Enter}");
expect((onChange.mock.lastCall![0] as Product[])[1]!.price).toBe(75);
expect(active()).toBe("2,1");Enter commits and moves down; Tab commits and moves right
const onChange = vi.fn();
await render(<Grid onChange={onChange} />);
await click(1, 0);
await press("Kettle{Tab}");
expect((onChange.mock.lastCall![0] as Product[])[1]!.name).toBe("Kettle");
expect(active()).toBe("1,1");
await expect.poll(() => cellElement(1, 1).contains(document.activeElement)).toBe(true);What is typed is read as the column's kind reads it
const onChange = vi.fn();
await render(<Grid onChange={onChange} />);
await click(2, 1);
await press("12*3{Enter}");
await click(2, 3);
await press("10/20/2026{Enter}");
const row = (onChange.mock.lastCall![0] as Product[])[2]!;
expect(row.price).toBe(36);
expect(row.released).toBe("2026-10-20");
expect(cellText(2, 3)).toBe("Oct 20, 2026");Escape abandons the edit and leaves the cell as it was
const onChange = vi.fn();
await render(<Grid onChange={onChange} />);
await click(1, 0);
await press("Kettle{Escape}");
expect(onChange).not.toHaveBeenCalled();
expect(cellText(1, 0)).toBe("Item 2");
expect(editor()).toBeNull();
expect(cellElement(1, 0).contains(document.activeElement)).toBe(true);Text that cannot be read keeps the cell in editing, marked invalid
const onChange = vi.fn();
await render(<Grid onChange={onChange} />);
await click(1, 1);
await press("lots{Enter}");
expect(editor()).not.toBeNull();
expect(editor()!.getAttribute("aria-invalid")).toBe("true");
expect(onChange).not.toHaveBeenCalled();A double click begins editing
const screen = await render(<Grid />);
await userEvent.dblClick(screen.getByRole("gridcell", { name: "Item 3", exact: true }));
await expect.poll(() => editor()?.value).toBe("Item 3");A choice opens its list, and choosing ends the edit
const onChange = vi.fn();
const screen = await render(<Grid onChange={onChange} />);
await click(0, 2);
await press("{Enter}");
await userEvent.click(screen.getByRole("option", { name: "Live" }));
expect((onChange.mock.lastCall![0] as Product[])[0]!.status).toBe("live");
await expect.poll(() => cellText(0, 2)).toBe("Live");Space turns a yes-or-no cell on and off, without editing
const onChange = vi.fn();
await render(<Grid onChange={onChange} />);
await click(1, 4);
await press(" ");
expect((onChange.mock.lastCall![0] as Product[])[1]!.featured).toBe(true);
await press(" ");
expect((onChange.mock.lastCall![0] as Product[])[1]!.featured).toBe(false);Delete clears every selected cell
const onChange = vi.fn();
await render(<Grid onChange={onChange} />);
await click(0, 0);
await press("{Shift>}{ArrowDown}{ArrowRight}{/Shift}{Delete}");
const rows = onChange.mock.lastCall![0] as Product[];
expect(rows.slice(0, 3).map((row) => [row.name, row.price])).toEqual([
[null, null],
[null, null],
["Item 3", 30],
]);An edit replaces the row it is in, and no other
const onChange = vi.fn();
await render(<Grid onChange={onChange} />);
await click(1, 1);
await press("75{Enter}");
const first = onChange.mock.lastCall![0] as Product[];
await click(4, 0);
await press("Kettle{Enter}");
const second = onChange.mock.lastCall![0] as Product[];
expect(second[1]).toBe(first[1]);
expect(second[4]).not.toBe(first[4]);
expect(active()).toBe("5,0");A grid that is given no way to change its rows cannot be edited
const screen = await render(<Grid readOnly />);
await expect.element(grid(screen)).toHaveAttribute("aria-readonly", "true");
await click(1, 0);
await press("{Enter}Kettle");
expect(editor()).toBeNull();
expect(cellText(1, 0)).toBe("Item 2");Begun by typing, an edit ends with the arrow that moves on: the value is not stepped
const onChange = vi.fn();
await render(<Grid onChange={onChange} />);
await click(1, 1);
await press("75{ArrowDown}");
expect((onChange.mock.lastCall![0] as Product[])[1]!.price).toBe(75);
expect(active()).toBe("2,1");
expect(editor()).toBeNull();Opened for editing, the arrows move within the text; F2 goes from the one way to the other
await render(<Grid />);
await click(1, 0);
await press("{Enter}{ArrowLeft}x{Enter}");
expect(cellText(1, 0)).toBe("Item x2");
await click(3, 0);
await press("ab{F2}{ArrowLeft}c{Enter}");
expect(cellText(3, 0)).toBe("acb");Shift with Tab at the start of a row goes to the end of the row above
await render(<Grid />);
await click(1, 0);
await press("Kettle{Shift>}{Tab}{/Shift}");
expect(active()).toBe("0,4");After Tabs across a row, Enter goes to the next row in the column the Tabs began in
await render(<Grid />);
await click(1, 0);
await press("Kettle{Tab}5{Enter}");
expect(active()).toBe("2,0");In text of several lines, Enter commits, and Alt with Enter makes a new line
const note = schema({
kind: "object",
fields: [{ key: "note", schema: { kind: "text", label: "Note", multiline: true } }],
});
function Notes() {
const [rows, setRows] = useState<Product[]>([{ note: "One" }, { note: "Two" }]);
return (
<DataGrid
aria-label="Notes"
schema={note}
rows={rows}
onRowsChange={setRows}
style={{ height: 200, width: 300 }}
/>
);
}
await render(<Notes />);
await click(0, 0);
await press("First{Alt>}{Enter}{/Alt}second{Enter}");
expect(active()).toBe("1,0");
expect(cellElement(0, 0).textContent).toBe("First\nsecond");An edit that cannot be read is not left by Tab, by an arrow, or by a press elsewhere; it says why
const onChange = vi.fn();
const screen = await render(<Grid onChange={onChange} />);
await click(1, 1);
await press("lots{Tab}{ArrowDown}");
await click(3, 0);
expect(active()).toBe("1,1");
expect(editor()!.value).toBe("lots");
expect(document.activeElement).toBe(editor());
await expect.element(screen.getByText("Not a value this column takes.").first()).toBeVisible();
await expect.element(status(screen)).toHaveTextContent("Not a value this column takes.");
expect(editor()!.getAttribute("aria-describedby")).not.toBeNull();
expect(onChange).not.toHaveBeenCalled();Leaving the grid commits what was typed; what cannot be read stays as typed, marked
const onChange = vi.fn();
const screen = await render(<Grid onChange={onChange} />);
await click(1, 1);
await press("75");
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
expect((onChange.mock.lastCall![0] as Product[])[1]!.price).toBe(75);
await click(2, 1);
await press("lots");
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
expect(editor()!.value).toBe("lots");
expect(editor()!.getAttribute("aria-invalid")).toBe("true");Escape pressed to an input method that is composing does not abandon the edit
await render(<Grid />);
await click(1, 0);
await press("Ket");
editor()!.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", isComposing: true, bubbles: true }),
);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(editor()!.value).toBe("Ket");The first key of an input method's composition begins editing, and composes in the cellChromium
Not yet written.
A key held down opens one cell, not each in turn
await render(<Grid />);
await click(1, 0);
document.activeElement!.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", repeat: true, bubbles: true, cancelable: true }),
);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(editor()).toBeNull();With the command key, Enter gives what was typed to every selected cell
const onChange = vi.fn();
await render(<Grid count={4} onChange={onChange} />);
await click(0, 1);
await press(`{Shift>}{ArrowDown}{ArrowDown}{/Shift}5{${COMMAND}>}{Enter}{/${COMMAND}}`);
expect(prices(onChange.mock.lastCall![0] as Product[])).toEqual([5, 5, 5, 40]);
expect(selected()).toEqual(["0,1", "1,1", "2,1"]);Typing on a choice chooses the option that starts with what was typed
const onChange = vi.fn();
await render(<Grid onChange={onChange} />);
await click(0, 2);
await press("l");
expect((onChange.mock.lastCall![0] as Product[])[0]!.status).toBe("live");Escape closes a choice's list, and the keys are the grid's again
const screen = await render(<Grid />);
await click(0, 2);
await press("{Enter}");
await expect.element(screen.getByRole("option", { name: "Live" })).toBeVisible();
await press("{Escape}");
await expect
.poll(() => cellElement(0, 2).querySelector("button")!.getAttribute("aria-expanded"))
.toBe("false");
await press("{ArrowDown}");
expect(active()).toBe("1,2");A cell holding what its field does not allow is marked, and says why
await render(<Grid />);
await click(1, 0);
await press("{Delete}");
await expect.poll(() => cellElement(1, 0).getAttribute("aria-invalid")).toBe("true");
expect(cellElement(1, 0).title).toBe("Required.");
expect(cellElement(1, 3).getAttribute("aria-invalid")).toBeNull();Adding and removing records
After the last record is a row that is not one yet: typing in it makes a record, and another row waits after it
const onChange = vi.fn();
await render(<Grid count={2} onChange={onChange} />);
await click(2, 0);
await press("Kettle{Tab}35{Enter}");
const rows = onChange.mock.lastCall![0] as Product[];
expect(rows).toHaveLength(3);
expect(rows[2]).toEqual({ name: "Kettle", price: 35, status: null, released: null, featured: null });
expect(active()).toBe("3,0");
expect(cellElement(3, 0)).not.toBeNull();The row that is not yet a record is named as one to its header, and nothing in it is marked as missing
const screen = await render(<Grid count={2} />);
await expect.element(screen.getByRole("rowheader", { name: "New row" })).toBeVisible();
expect(cellElement(2, 0).getAttribute("aria-invalid")).toBeNull();A grid that cannot be given records has no such row
const screen = await render(<Grid count={2} readOnly />);
await expect.element(grid(screen)).toHaveAttribute("aria-rowcount", "3");
expect(cellElement(2, 0)).toBeNull();The arrows step on into that row; the command key with an arrow, and selecting a column or everything, stop at the last record
await render(<Grid count={3} />);
await click(2, 1);
await press("{ArrowDown}");
expect(active()).toBe("3,1");
await press(`{ArrowUp}{ArrowUp}{${COMMAND}>}{ArrowDown}{/${COMMAND}}`);
expect(active()).toBe("2,1");
await press(`{${COMMAND}>}a{/${COMMAND}}`);
expect(selected()).toHaveLength(15);Nothing is not worth a record: clearing that row, or typing in it and taking the typing back, makes none
const onChange = vi.fn();
await render(<Grid count={2} onChange={onChange} />);
await click(2, 0);
await press("{Delete}");
await press("a{Backspace}{Enter}");
expect(onChange).not.toHaveBeenCalled();A press on a row's header selects the row; with Shift, the rows from the active cell's to it
const screen = await render(<Grid count={4} />);
await click(1, 1);
await userEvent.click(screen.getByRole("rowheader", { name: "3", exact: true }));
expect(selected()).toEqual(["2,0", "2,1", "2,2", "2,3", "2,4"]);
expect(active()).toBe("2,1");
await userEvent.click(screen.getByRole("rowheader", { name: "1", exact: true }), {
modifiers: ["Shift"],
});
expect(selected()).toHaveLength(15);
expect(active()).toBe("2,1");The corner where the headers meet selects every cell
const screen = await render(<Grid count={3} />);
await click(1, 1);
await userEvent.click(screen.getByRole("columnheader", { name: "Select all" }));
expect(selected()).toHaveLength(15);
expect(active()).toBe("1,1");With the command key, Backspace removes the rows that are selected whole, and says so
const onChange = vi.fn();
const screen = await render(<Grid count={5} onChange={onChange} />);
await click(1, 0);
await press("{Shift>} {ArrowDown}{/Shift}");
await press(`{${COMMAND}>}{Backspace}{/${COMMAND}}`);
const rows = onChange.mock.lastCall![0] as Product[];
expect(rows.map((row) => row.name)).toEqual(["Item 1", "Item 4", "Item 5"]);
expect(active()).toBe("1,0");
await expect.element(status(screen)).toHaveTextContent("Removed 2 rows.");Rows of which only some cells are selected are not removed
const onChange = vi.fn();
await render(<Grid count={5} onChange={onChange} />);
await click(1, 0);
await press(`{Shift>}{ArrowRight}{/Shift}{${COMMAND}>}{Backspace}{/${COMMAND}}`);
expect(onChange).not.toHaveBeenCalled();Removing rows is undone by putting back the very records, where they were
const onChange = vi.fn();
await render(<Grid count={5} onChange={onChange} />);
const before = make(5).map((row) => row.name);
await click(1, 0);
await press("{Shift>} {/Shift}");
await press(`{${COMMAND}>}{Backspace}{/${COMMAND}}`);
await click(3, 0);
await press("{Shift>} {/Shift}");
await press(`{${COMMAND}>}{Backspace}{/${COMMAND}}`);
await press(`{${COMMAND}>}z{/${COMMAND}}{${COMMAND}>}z{/${COMMAND}}`);
expect((onChange.mock.lastCall![0] as Product[]).map((row) => row.name)).toEqual(before);
expect(selected()).toEqual(["1,0", "1,1", "1,2", "1,3", "1,4"]);A record made by typing is taken away again by one undo
const onChange = vi.fn();
await render(<Grid count={2} onChange={onChange} />);
await click(2, 0);
await press("Kettle{Enter}");
expect(onChange.mock.lastCall![0]).toHaveLength(3);
await press(`{${COMMAND}>}z{/${COMMAND}}`);
expect(onChange.mock.lastCall![0]).toHaveLength(2);Undoing
The command key with Z undoes the last change; with Shift, or with Y, it is done again
const onChange = vi.fn();
await render(<Grid onChange={onChange} />);
await click(1, 1);
await press("75{Enter}");
await press(`{${COMMAND}>}z{/${COMMAND}}`);
expect(prices(onChange.mock.lastCall![0] as Product[])[1]).toBe(20);
await press(`{${COMMAND}>}{Shift>}z{/Shift}{/${COMMAND}}`);
expect(prices(onChange.mock.lastCall![0] as Product[])[1]).toBe(75);
await press(`{${COMMAND}>}z{/${COMMAND}}{${COMMAND}>}y{/${COMMAND}}`);
expect(prices(onChange.mock.lastCall![0] as Product[])[1]).toBe(75);One undo takes back everything one action did, and selects the cells it restored
const onChange = vi.fn();
const screen = await render(<Grid count={4} onChange={onChange} />);
await click(0, 0);
await press("{Shift>}{ArrowDown}{ArrowRight}{/Shift}{Delete}{ArrowDown}{ArrowDown}{ArrowDown}");
await press(`{${COMMAND}>}z{/${COMMAND}}`);
const rows = onChange.mock.lastCall![0] as Product[];
expect(rows.map((row) => [row.name, row.price])).toEqual(make(4).map((row) => [row.name, row.price]));
expect(selected()).toEqual(["0,0", "0,1", "1,0", "1,1"]);
await expect.element(status(screen)).toHaveTextContent("Undone.");With nothing to undo, nothing happens
const onChange = vi.fn();
await render(<Grid onChange={onChange} />);
await click(1, 1);
await press(`{${COMMAND}>}z{/${COMMAND}}`);
expect(onChange).not.toHaveBeenCalled();Rows replaced by their owner are other rows: what was done to the old ones is not undone in them
const onChange = vi.fn();
const screen = await render(<Grid onChange={onChange} />);
await click(1, 1);
await press("75{Enter}");
(screen.getByRole("button", { name: "Load others" }).element() as HTMLElement).click();
await expect.poll(() => cellText(1, 1)).toBe("20");
onChange.mockClear();
await click(0, 0);
await press(`{${COMMAND}>}z{/${COMMAND}}`);
expect(onChange).not.toHaveBeenCalled();Clipboard
Copying puts the selected cells on the clipboard as a spreadsheet would, as they are shownChromium, Chromium · touch, WebKit, WebKit · touch
Not yet written.
Pasting a block fills cells from the active cell, each read as its column's kindChromium, Chromium · touch, WebKit, WebKit · touch
Not yet written.
Pasting one cell fills every selected cell with itChromium, Chromium · touch, WebKit, WebKit · touch
Not yet written.
Pasted text that is not a value of the column's kind leaves that cell as it wasChromium, Chromium · touch, WebKit, WebKit · touch
Not yet written.
A block that reaches past the last column is cut off there; past the last record, it makes records to hold itChromium, Chromium · touch, WebKit, WebKit · touch
Not yet written.
Where records cannot be added, a block is cut off at the last oneChromium, Chromium · touch, WebKit, WebKit · touch
Not yet written.
Cutting copies the cells and clears themChromium, Chromium · touch, WebKit, WebKit · touch
Not yet written.
With every cell selected, cutting copies every cell: what is cleared is what was copiedChromium, Chromium · touch, WebKit, WebKit · touch
Not yet written.
With cells selected in separate places, cutting takes the active range and leaves the othersChromium, WebKit
Not yet written.
A clipboard that holds no text changes nothing
const onChange = vi.fn();
const screen = await render(<Grid onChange={onChange} />);
await click(0, 0);
await press("{Shift>}{ArrowDown}{ArrowRight}{/Shift}");
gridElement(screen).dispatchEvent(
new ClipboardEvent("paste", { clipboardData: new DataTransfer(), bubbles: true, cancelable: true }),
);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onChange).not.toHaveBeenCalled();A block is repeated over a selection it divides evenlyChromium, Chromium · touch, WebKit, WebKit · touch
Not yet written.
A paste says how many cells it filled, and how many it left because their text could not be readChromium, Chromium · touch, WebKit, WebKit · touch
Not yet written.
Copying offers the cells as a table too, for what reads oneChromium, Chromium · touch, WebKit, WebKit · touch
Not yet written.
The menu
Shift with F10, and the Menu key, open a menu of what can be done with the selection, against the active cell
const screen = await render(<Grid />);
await click(2, 1);
await ask(screen);
// Asked for by a key, it is entered at its first item that can be chosen.
await expect.element(item(screen, "Cut")).toHaveFocus();
expect(
screen
.getByRole("menuitem")
.elements()
.map((element) => element.firstChild?.textContent),
).toEqual([
"Cut",
"Copy",
"Paste",
"Clear",
"Fill down",
"Fill across",
"Fill series",
"Insert row above",
"Delete row",
]);
if (!hasTouch) {
await settled();
const cell = cellElement(2, 1).getBoundingClientRect();
const box = menu(screen).element().getBoundingClientRect();
expect(Math.abs(box.top - cell.bottom)).toBeLessThan(12);
expect(Math.abs(box.left - cell.left)).toBeLessThan(12);
}
await press("{Escape}");
await expect.poll(() => menuOpen(screen)).toBe(false);
await ask(screen, "{ContextMenu}");Closing the menu returns focus to the active cell, and the keys are the grid's again
const screen = await render(<Grid />);
await click(2, 0);
await ask(screen);
await press("{Escape}");
await expect.poll(() => cellElement(2, 0).contains(document.activeElement)).toBe(true);
await press("{ArrowDown}");
expect(active()).toBe("3,0");A secondary click opens it at the pointer, for the cell it lands onChromium, Firefox, WebKit
Not yet written.
A secondary click on a row's header selects the row, unless it is selected already, and opens the menu for itChromium, Firefox, WebKit
Not yet written.
Clear empties the selected cells, and Delete row removes every row the selection is in
const onChange = vi.fn();
const screen = await render(<Grid count={4} onChange={onChange} />);
await click(1, 0);
await press("{Shift>}{ArrowDown}{/Shift}");
await ask(screen);
await choose(screen, "Clear");
expect(names(4)).toEqual(["Item 1", "", "", "Item 4"]);
await ask(screen);
// Named for how many rows that is.
await choose(screen, "Delete 2 rows");
await expect.poll(() => names(2)).toEqual(["Item 1", "Item 4"]);
await expect.element(status(screen)).toHaveTextContent("Removed 2 rows.");
expect(onChange.mock.lastCall![0]).toHaveLength(2);Insert row above puts an empty record in above the selection — as many as the rows it is in — and says so; one undo takes them out
const onChange = vi.fn();
const screen = await render(<Grid count={3} onChange={onChange} />);
await click(1, 0);
await ask(screen);
await choose(screen, "Insert row above");
await expect.poll(() => names(4)).toEqual(["Item 1", "", "Item 2", "Item 3"]);
await expect.element(status(screen)).toHaveTextContent("Inserted 1 row.");
// The new row is what is selected, with focus in it.
expect(selected()).toHaveLength(5);
await expect.poll(() => cellElement(1, 0).contains(document.activeElement)).toBe(true);
await press("{ArrowDown}{Shift>}{ArrowDown}{/Shift}");
await ask(screen);
await choose(screen, "Insert 2 rows above");
await expect.poll(() => names(6)).toEqual(["Item 1", "", "", "", "Item 2", "Item 3"]);
await press(`{${COMMAND}>}z{/${COMMAND}}`);
await expect.poll(() => names(4)).toEqual(["Item 1", "", "Item 2", "Item 3"]);Copy puts the selection on the clipboard as text and as a table, and says how many cells
const written: ClipboardItem[][] = [];
const write = vi.spyOn(navigator.clipboard, "write").mockImplementation(async (items) => {
written.push(items);
});
try {
const screen = await render(<Grid />);
await click(0, 0);
await press("{Shift>}{ArrowRight}{ArrowDown}{/Shift}");
await ask(screen);
await choose(screen, "Copy");
await expect.element(status(screen)).toHaveTextContent("Copied 4 cells.");
const [copied] = written[0]!;
expect(await (await copied!.getType("text/plain")).text()).toBe("Item 1\t10\nItem 2\t20");
expect(await (await copied!.getType("text/html")).text()).toContain("<td>Item 2</td><td>20</td>");
} finally {
write.mockRestore();
}Cut clears what it copied once it has been copied; where the clipboard cannot be written, nothing is cleared and that is said
const onChange = vi.fn();
const write = vi.spyOn(navigator.clipboard, "write").mockRejectedValue(new Error("denied"));
try {
const screen = await render(<Grid onChange={onChange} />);
await click(1, 0);
await ask(screen);
await choose(screen, "Cut");
await expect
.element(status(screen))
.toHaveTextContent(
"The clipboard could not be written to from here. Copy with the keyboard instead.",
);
expect(onChange).not.toHaveBeenCalled();
write.mockResolvedValue(undefined);
await ask(screen);
await choose(screen, "Cut");
await expect.poll(() => cellText(1, 0)).toBe("");
} finally {
write.mockRestore();
}Paste reads the clipboard where the browser permits, and pastes as the keyboard's paste does
const read = vi.spyOn(navigator.clipboard, "readText").mockResolvedValue("Kettle\t5\nJug\t6");
try {
const screen = await render(<Grid />);
await click(1, 0);
await ask(screen);
await choose(screen, "Paste");
await expect.element(status(screen)).toHaveTextContent("Pasted 4 cells.");
expect([cellText(1, 0), cellText(1, 1), cellText(2, 0), cellText(2, 1)]).toEqual([
"Kettle",
"5",
"Jug",
"6",
]);
} finally {
read.mockRestore();
}Where the browser does not permit the clipboard to be read, Paste changes nothing and says so
const onChange = vi.fn();
const read = vi
.spyOn(navigator.clipboard, "readText")
.mockRejectedValue(new DOMException("denied", "NotAllowedError"));
try {
const screen = await render(<Grid onChange={onChange} />);
await click(1, 0);
await ask(screen);
await choose(screen, "Paste");
await expect
.element(status(screen))
.toHaveTextContent("The clipboard could not be read from here. Paste with the keyboard instead.");
expect(onChange).not.toHaveBeenCalled();
} finally {
read.mockRestore();
}What cannot be done is there and cannot be chosen: in a grid that only shows, everything but Copy
const screen = await render(<Grid readOnly />);
await click(1, 0);
await ask(screen);
expect(disabled(screen)).toEqual([
"Cut",
"Paste",
"Clear",
"Fill down",
"Fill across",
"Fill series",
"Insert row above",
"Delete row",
]);
await expect.element(item(screen, "Copy")).toHaveFocus();With only read-only columns selected, nothing that would change them can be chosen
const stock = schema({
kind: "object",
fields: [
{ key: "sku", schema: { kind: "text", label: "SKU", readOnly: true } },
{ key: "count", schema: { kind: "number", label: "Count" } },
],
});
const screen = await render(
<DataGrid
aria-label="Products"
schema={stock}
rows={[{ sku: "A-1", count: 3 }]}
onRowsChange={() => undefined}
style={{ height: 240, width: 480 }}
/>,
);
await click(0, 0);
await ask(screen);
expect(disabled(screen)).toEqual(["Cut", "Paste", "Clear", "Fill down", "Fill across", "Fill series"]);
await press("{Escape}");
await click(0, 1);
await ask(screen);
// The first row has no row above to fill from; the second column has the first; one cell is no series.
expect(disabled(screen)).toEqual(["Fill down", "Fill series"]);While a cell is being edited, the menu asked for is its field's own
const screen = await render(<Grid />);
await click(1, 0);
await press("{Enter}");
const field = cellElement(1, 0).querySelector("input")!;
const asked = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
field.dispatchEvent(asked);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(asked.defaultPrevented).toBe(false);
expect(menuOpen(screen)).toBe(false);Has no accessibility violations when open
const screen = await render(<Grid />);
await click(1, 0);
await ask(screen);
await expectAccessible(screen.container);Filling
Under a mouse, the selection has a small handle at its end cornerChromium, Firefox, WebKit
Not yet written.
Dragging the handle down fills the cells it passes over by repeating the selection, as one change that one undo takes backChromium, Firefox, WebKit
Not yet written.
Numbers a constant step apart are continued; one number, or text, is repeatedChromium, Firefox, WebKit
Not yet written.
Dates a constant number of days apart are continuedChromium, Firefox, WebKit
Not yet written.
Dragged upward, the handle fills back from the selection's startChromium, Firefox, WebKit
Not yet written.
The cells a drag would fill are outlined as it goes, along one axis only; Escape takes the drag backChromium, Firefox, WebKit
Not yet written.
A handle pressed and let go without being dragged out of the selection fills nothingChromium, Firefox, WebKit
Not yet written.
Dragged to the side, it fills along the columns: a series through columns of its kindChromium, Firefox, WebKit
Not yet written.
A read-only cell in the way of a fill is left as it was, and that is saidChromium, Firefox, WebKit
Not yet written.
Between columns of different kinds a value goes as its text; what the column cannot read is left, and that is saidChromium, Firefox, WebKit
Not yet written.
A fill dragged into the row that is not yet a record makes oneChromium, Firefox, WebKit
Not yet written.
A grid that only shows has no handle to fill byChromium, Firefox, WebKit
Not yet written.
The command key with D fills the selection down from its first row, and with R across from its first column
const onChange = vi.fn();
const screen = await render(<Sums onChange={onChange} />);
await click(0, 0);
await press("{Shift>}{ArrowDown}{ArrowRight}{/Shift}");
await press(`{${COMMAND}>}d{/${COMMAND}}`);
expect([cellText(1, 0), cellText(1, 1)]).toEqual(["1", "3"]);
await expect.element(status(screen)).toHaveTextContent("Filled 2 cells.");
// The selection is as it was.
expect(selected()).toEqual(["0,0", "0,1", "1,0", "1,1"]);
await click(0, 0);
await press("{Shift>}{ArrowRight}{ArrowRight}{/Shift}");
await press(`{${COMMAND}>}r{/${COMMAND}}`);
// Copied, not continued: only a drag from two cells says there is a series.
expect([cellText(0, 1), cellText(0, 2)]).toEqual(["1", "1"]);
expect(onChange).toHaveBeenCalledTimes(2);With one row selected, filling down takes the row above; in the first row there is nothing to take
const onChange = vi.fn();
await render(<Sums onChange={onChange} />);
await click(0, 0);
await press(`{${COMMAND}>}d{/${COMMAND}}`);
expect(onChange).not.toHaveBeenCalled();
await press(`{ArrowDown}{${COMMAND}>}d{/${COMMAND}}`);
expect(cellText(1, 0)).toBe("1");
// What was selected still is, and no more.
expect(selected()).toEqual(["1,0"]);The menu's Fill series continues the series that the values at the selection's start make, through the rest of it: under a finger, where there is no handle, too
const onChange = vi.fn();
const screen = await render(<Grid onChange={onChange} />);
// Prices 10 and 20 begin the column; the three below are emptied.
await click(2, 1);
await press("{Shift>}{ArrowDown}{ArrowDown}{/Shift}{Delete}");
expect([cellText(2, 1), cellText(4, 1)]).toEqual(["", ""]);
await click(0, 1);
await press("{Shift>}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{/Shift}{Shift>}{F10}{/Shift}");
const series = screen.getByRole("menuitem", { name: "Fill series" });
await settled();
onChange.mockClear();
if (hasTouch) await commands.tap(series.selector);
else await userEvent.click(series);
await expect
.poll(() => [0, 1, 2, 3, 4].map((row) => cellText(row, 1)))
.toEqual(["10", "20", "30", "40", "50"]);
// One change, said as a fill is.
expect(onChange).toHaveBeenCalledOnce();
await expect.element(status(screen)).toHaveTextContent("Filled 3 cells.");The menu fills down and across too, for a pointer with no handle to drag
const screen = await render(<Sums />);
await click(0, 0);
await press("{Shift>}{ArrowDown}{/Shift}{Shift>}{F10}{/Shift}");
const down = screen.getByRole("menuitem", { name: "Fill down" });
await settled();
if (hasTouch) await commands.tap(down.selector);
else await userEvent.click(down);
await expect.poll(() => cellText(1, 0)).toBe("1");Touch
A tap makes a cell active, and a tap on the active cell edits itChromium · touch, WebKit · touch
Not yet written.
Under a finger, a cell holds no field until it is edited, so that selecting it brings up no keyboardChromium · touch, WebKit · touch
Not yet written.
On a touchscreen, every row is at least 44 pixels tallChromium · touch, WebKit · touch
Not yet written.
Under a finger, the selection has a handle at its start corner and one at its end, each at least 44 pixels acrossChromium · touch, WebKit · touch
Not yet written.
Dragging the end handle extends the selection from the active cell; dragging the start handle redraws it from its end, which becomes the active cellChromium · touch, WebKit · touch
Not yet written.
A finger that rests on a handle before dragging it still drags it, and opens no menuChromium · touch, WebKit · touch
Not yet written.
A handle takes in the next row once the finger is half a row into it, and not beforeChromium · touch, WebKit · touch
Not yet written.
A handle dragged to within reach of the grid's edge scrolls the grid, selecting as it goesChromium · touch, WebKit · touch
Not yet written.
A mouse lying over the grid does not end a finger's drag: only the pointer that began it doesChromium · touch, WebKit · touch
Not yet written.
A handle taken where it already is within reach of an edge does not scroll the grid until it is dragged on towards the edgeChromium · touch, WebKit · touch
Not yet written.
A handle that is tapped, not dragged, is not in the way of the cell beneath itChromium · touch, WebKit · touch
Not yet written.
A handle at the edge of the cells lies over the headers, where the whole of it can be pressed; tapped there, it is not in the way of the headerChromium · touch, WebKit · touch
Not yet written.
A handle whose corner is scrolled out from the cells in view is not shown over the headersChromium · touch, WebKit · touch
Not yet written.
A finger that rests on a cell selects it and opens the menu for it; what is chosen there is done, and focus is the cell's againChromium · touch, WebKit · touch
Not yet written.
A tap on the selection, but for its active cell, opens the menu and leaves the selection as it isChromium · touch, WebKit · touch
Not yet written.
API
onCellsChange hears each edit as the cells it changed, with what each held before
const changes: CellChange[][] = [];
function Cells() {
const [rows, setRows] = useState(() => make(3));
return (
<DataGrid
aria-label="Products"
schema={product}
locale="en-US"
rows={rows}
onCellsChange={(changed) => {
changes.push([...changed]);
setRows((all) =>
all.map((row, index) => {
const mine = changed.filter((change) => change.row === index);
return mine.length === 0
? row
: { ...row, ...Object.fromEntries(mine.map((change) => [change.key, change.value])) };
}),
);
}}
style={{ height: 300, width: 640 }}
/>
);
}
await render(<Cells />);
await click(1, 1);
await press("75{Enter}");
expect(changes).toEqual([[{ row: 1, key: "price", value: 75, previous: 20 }]]);
await expect.poll(() => cellText(1, 1)).toBe("75");
await press(`{${COMMAND}>}z{/${COMMAND}}`);
expect(changes[1]).toEqual([{ row: 1, key: "price", value: 20, previous: 75 }]);onCellsChange hears a fill once, as every cell it changed
const onCellsChange = vi.fn();
await render(<Grid count={3} onCellsChange={onCellsChange} />);
await click(0, 0);
await press(`{Shift>}{ArrowDown}{ArrowDown}{/Shift}{${COMMAND}>}d{/${COMMAND}}`);
expect(onCellsChange).toHaveBeenCalledTimes(1);
expect(onCellsChange).toHaveBeenCalledWith([
{ row: 1, key: "name", value: "Item 1", previous: "Item 2" },
{ row: 2, key: "name", value: "Item 1", previous: "Item 3" },
]);onRowsInsert and onRowsRemove hear records added and removed, as where and which
const onRowsInsert = vi.fn();
const onRowsRemove = vi.fn();
await render(<Grid count={3} onRowsInsert={onRowsInsert} onRowsRemove={onRowsRemove} />);
await click(3, 0);
await press("Kettle{Enter}");
expect(onRowsInsert).toHaveBeenCalledWith(3, [
{ name: "Kettle", price: null, status: null, released: null, featured: null },
]);
await click(0, 0);
await press("{Shift>} {/Shift}");
await press(`{${COMMAND}>}{Backspace}{/${COMMAND}}`);
expect(onRowsRemove.mock.lastCall![0]).toEqual([{ at: 0, rows: [make(1)[0]] }]);onColumnWidthsChange hears a resize, with every width that has been changed; controlled widths change only when their owner changes them
const onColumnWidthsChange = vi.fn();
await render(<Grid columnWidths={{ price: 200 }} onColumnWidthsChange={onColumnWidthsChange} />);
expect(cellElement(0, 1).getBoundingClientRect().width).toBe(200);
await click(1, 0);
await press("{Alt>}{Shift>}{ArrowRight}{/Shift}{/Alt}");
expect(onColumnWidthsChange).toHaveBeenCalledWith({ price: 200, name: 136 });
expect(cellElement(0, 0).getBoundingClientRect().width).toBe(120);onSortChange hears the sort asked for; a controlled sort changes only when its owner changes it
const onSortChange = vi.fn();
const screen = await render(<Grid count={3} sort={null} onSortChange={onSortChange} />);
await userEvent.click(screen.getByRole("button", { name: "Sort by Price" }));
expect(onSortChange).toHaveBeenCalledWith({ key: "price", direction: "ascending" });
await expect
.element(screen.getByRole("columnheader", { name: "Price" }))
.toHaveAttribute("aria-sort", "none");columnOrder shows the columns in the order of their keys, with any it does not name after; controlled, it changes only when its owner changes it, and onColumnOrderChange hears the order asked for
const onColumnOrderChange = vi.fn();
const screen = await render(
<Grid columnOrder={["status", "name"]} onColumnOrderChange={onColumnOrderChange} />,
);
const headers = () =>
screen
.getByRole("columnheader")
.elements()
.slice(1)
.map((element) => element.textContent);
expect(headers()).toEqual(["Status", "Name", "Price", "Released", "Featured"]);
expect(cellText(0, 0)).toBe("Draft");
await click(0, 1);
await press("{ArrowUp}{Alt>}{ArrowLeft}{/Alt}");
expect(onColumnOrderChange).toHaveBeenCalledWith(["name", "status", "price", "released", "featured"]);
expect(headers()[0]).toBe("Status");defaultColumnOrder is the order the columns begin in, and the grid keeps it from there
const screen = await render(<Grid defaultColumnOrder={["price"]} />);
const first = () => screen.getByRole("columnheader").elements()[1]!.textContent;
expect(first()).toBe("Price");
await click(0, 0);
await press("{ArrowUp}{Alt>}{ArrowRight}{/Alt}");
expect(first()).toBe("Name");issues marks the cells its owner knows a problem in, each by its record's place and its field, as a cell the schema does not allow is marked
const screen = await render(
<Grid
count={3}
issues={[
{ path: [0, "name"], code: "unique" },
{ path: [0, "name"], code: "pattern" },
]}
/>,
);
await expect.element(grid(screen)).toBeVisible();
expect(cellElement(0, 0).getAttribute("aria-invalid")).toBe("true");
// Of two problems in one cell, the first is the one it says.
expect(cellElement(0, 0).title).toBe("Already used.");
expect(cellElement(1, 0).getAttribute("aria-invalid")).toBeNull();
expect(cellElement(0, 1).getAttribute("aria-invalid")).toBeNull();
// The mark is the record's, wherever the record is shown.
await click(1, 1);
await press("{Alt>}{Shift>}{ArrowDown}{/Shift}{/Alt}");
expect(cellText(1, 0)).toBe("Item 2");
expect([0, 1, 2].map((row) => cellElement(row, 0).getAttribute("aria-invalid"))).toEqual([
null,
null,
"true",
]);
await expectAccessible(screen.container);A problem the schema states is the one a cell says, before any its owner adds
const screen = await render(<Grid count={2} issues={[{ path: [0, "name"], code: "unique" }]} />);
await expect.element(grid(screen)).toBeVisible();
await click(0, 0);
await press("{Delete}");
expect(cellElement(0, 0).title).toBe("Required.");onActiveCellChange hears the active cell move, as its record's place among the rows and its field's key
const onActiveCellChange = vi.fn();
await render(<Grid count={3} onActiveCellChange={onActiveCellChange} />);
// Where the grid begins is not a move.
expect(onActiveCellChange).not.toHaveBeenCalled();
await click(1, 1);
expect(onActiveCellChange).toHaveBeenLastCalledWith({ row: 1, key: "price" });
await press("{ArrowRight}");
expect(onActiveCellChange).toHaveBeenLastCalledWith({ row: 1, key: "status" });
expect(onActiveCellChange).toHaveBeenCalledTimes(2);
// Sorted, the row shown first holds the third record.
await press(`{${COMMAND}>}{Home}{/${COMMAND}}{ArrowRight}{Alt>}{Shift>}{ArrowDown}{/Shift}{/Alt}`);
await press("{ArrowUp}{ArrowUp}");
expect(onActiveCellChange).toHaveBeenLastCalledWith({ row: 2, key: "price" });
// The row that is not yet a record is one past the last.
await press(`{${COMMAND}>}{End}{/${COMMAND}}{ArrowDown}`);
expect(onActiveCellChange).toHaveBeenLastCalledWith({ row: 3, key: "featured" });A field given by reference has its column, as the field it refers to
const amount = schema({ kind: "number", label: "Amount" });
const ledger = schema({
kind: "object",
fields: [
{ key: "amount", schema: { kind: "lazy", schema: () => amount } },
{ key: "lines", schema: { kind: "list", item: { kind: "text" } } },
],
});
const screen = await render(
<DataGrid
aria-label="Ledger"
schema={ledger}
rows={[{ amount: 1250.5, lines: [] }]}
locale="en-US"
/>,
);
await expect.element(screen.getByRole("columnheader", { name: "Amount" })).toBeVisible();
expect(cellText(0, 0)).toBe("1,250.5");With rowHeaders off, rows have no headers and the columns are the fields alone
const screen = await render(<Grid rowHeaders={false} />);
await expect.element(grid(screen)).toHaveAttribute("aria-colcount", "5");
expect(screen.getByRole("rowheader").elements()).toHaveLength(0);A handler given to the grid runs before the grid's own, which it can prevent
const onScroll = vi.fn();
const screen = await render(
<Grid
count={500}
onScroll={onScroll}
onKeyDownCapture={(event) => {
if (event.key === "ArrowDown") event.preventDefault();
}}
/>,
);
await click(1, 0);
await press("{ArrowDown}{ArrowRight}");
expect(active()).toBe("1,1");
gridElement(screen).scrollTop = 300 * ROW;
await expect.poll(() => cellElement(300, 0)?.textContent).toBe("Item 301");
expect(onScroll).toHaveBeenCalled();API: locks
locks makes the locks the owner's: the grid shows them, and asks through onLocksChange for a change, heard before the change it came with
const heard: string[] = [];
const screen = await render(
<DataGrid
aria-label="Products"
schema={priced}
rows={[{ price: 1, total: 2 }]}
locks={new Map<string, Lock>([[lockKey("0", "price"), "held"]])}
onLocksChange={(locks) => heard.push(`locks ${[...locks.values()].join(" ")}`)}
onCellsChange={(cells) => heard.push(`cells ${cells.map((cell) => cell.key).join(" ")}`)}
style={{ height: 240, width: 480 }}
/>,
);
expect(cellElement(0, 0).getAttribute("data-lock")).toBe("held");
await click(0, 1);
await press("7{Enter}");
expect(heard).toEqual(["locks held frozen", "cells total"]);
// The owner did not take the lock, so the grid does not show it.
expect(cellElement(0, 1).getAttribute("data-lock")).toBeNull();
expect(screen.getByRole("grid").element()).toBeTruthy();messages gives the words for locks and computed values in another language
const screen = await render(
<DataGrid
aria-label="Products"
schema={priced}
rows={[{ price: 1, total: 2 }]}
onRowsChange={() => {}}
messages={{
hold: "Festhalten",
freeze: "Einfrieren",
computed: (formula) => `Berechnet: ${formula}`,
}}
style={{ height: 240, width: 480 }}
/>,
);
await expect
.element(screen.getByRole("columnheader", { name: "Total" }))
.toHaveAccessibleDescription("Berechnet: price * 2");
await click(0, 1);
await press("{Shift>}{F10}{/Shift}");
await expect.element(screen.getByRole("menuitem", { name: "Festhalten" })).toBeInTheDocument();
await expect.element(screen.getByRole("menuitem", { name: "Einfrieren" })).toBeInTheDocument();Column widths
Each column has an edge that says what it resizes, and how wide the column is
const screen = await render(<Grid />);
await expect.element(edge(screen, "Price")).toHaveAttribute("aria-valuenow", "120");
await expect.element(edge(screen, "Price")).toHaveAttribute("aria-orientation", "vertical");Dragging a column's edge makes the column wider or narrower by as far as it is draggedChromium, Firefox, WebKit
Not yet written.
A column is made no narrower than can be pressed, however far its edge is draggedChromium, Firefox, WebKit
Not yet written.
Dragging an edge selects nothing, and a press on it is not a press on the headerChromium, Firefox, WebKit
Not yet written.
Pressing an edge twice fits the column to its name and to what its cells hold
const screen = await render(<Grid count={3} />);
await userEvent.dblClick(edge(screen, "Released"));
// "Oct 13, 2026" in the cell's text, with the cell's padding: well under the 120 it began at.
await expect.poll(() => widthOf(3)).toBeLessThan(120);
expect(widthOf(3)).toBeGreaterThan(80);
const cell = cellElement(0, 3).querySelector("span")!;
expect(cell.scrollWidth).toBeLessThanOrEqual(cell.clientWidth);Alt and Shift with an arrow to the side move the edge of the active cell's column
await render(<Grid />);
await click(1, 1);
await press("{Alt>}{Shift>}{ArrowRight}{ArrowRight}{/Shift}{/Alt}");
expect(widthOf(1)).toBe(152);
await press("{Alt>}{Shift>}{ArrowLeft}{/Shift}{/Alt}");
expect(widthOf(1)).toBe(136);
expect(active()).toBe("1,1");An edge that has focus is moved by the arrows to the side
const screen = await render(<Grid />);
await click(1, 0);
(edge(screen, "Price").element() as HTMLElement).focus();
await press("{ArrowRight}{ArrowRight}{ArrowLeft}");
expect(widthOf(1)).toBe(136);
expect(active()).toBe("1,0");Right to left, an edge dragged leftward widens its column, and the arrows are mirrored
await render(
<div dir="rtl">
<Grid />
</div>,
);
await click(1, 0);
await press("{Alt>}{Shift>}{ArrowLeft}{/Shift}{/Alt}");
expect(widthOf(0)).toBe(136);Moving to a column brings it into view, whatever the widths of those before it
const screen = await render(
<Grid defaultColumnWidths={{ name: 400, price: 300 }} style={{ height: 300, width: 500 }} />,
);
await click(1, 0);
await press("{End}");
const view = gridElement(screen).getBoundingClientRect();
await expect
.poll(() => cellElement(1, 4).getBoundingClientRect().right)
.toBeLessThanOrEqual(view.right);
await press("{Home}{ArrowRight}");
// Not beneath the row headers, which stay over the start of the view.
const header = screen
.getByRole("rowheader", { name: "2", exact: true })
.element()
.getBoundingClientRect();
await expect
.poll(() => cellElement(1, 1).getBoundingClientRect().left)
.toBeGreaterThanOrEqual(header.right - 0.5);Column headers
The up arrow from a column's first cell goes to the column's header, and the down arrow comes back
const screen = await render(<Grid />);
await click(1, 1);
await press("{ArrowUp}");
// From the second row, to the first.
expect(active()).toBe("0,1");
expect(cellElement(0, 1).contains(document.activeElement)).toBe(true);
await press("{ArrowUp}");
// The header takes focus on its own control: the button that sorts by the column.
await expect.element(sortButton(screen, "Price")).toHaveFocus();
expect(active()).toBe("0,1");
await press("{ArrowDown}");
expect(cellElement(0, 1).contains(document.activeElement)).toBe(true);Only the plain up arrow leaves the cells: with Shift it extends nothing, and with the command key it stays at the first row
await render(<Grid />);
await click(0, 1);
await press(`{Shift>}{ArrowUp}{/Shift}{${COMMAND}>}{ArrowUp}{/${COMMAND}}`);
expect(cellElement(0, 1).contains(document.activeElement)).toBe(true);Along the headers, the arrows to the side, Home and End go from header to header
const screen = await render(<Grid />);
await click(0, 0);
await press("{ArrowUp}{ArrowRight}{ArrowRight}");
await expect.element(sortButton(screen, "Status")).toHaveFocus();
await press("{ArrowLeft}{ArrowLeft}{ArrowLeft}");
await expect.element(sortButton(screen, "Name")).toHaveFocus();
await press("{End}");
await expect.element(sortButton(screen, "Featured")).toHaveFocus();
await press("{Home}{ArrowRight}{ArrowDown}");
// Down from a header is that column's first cell, not the cell that was left.
expect(active()).toBe("0,1");
expect(cellElement(0, 1).contains(document.activeElement)).toBe(true);Enter or Space on a header sorts by its column, and focus stays on the header
const screen = await render(<Grid count={3} />);
await click(0, 1);
await press("{ArrowUp}{Enter}{Enter}");
await expect.element(header(screen, "Price")).toHaveAttribute("aria-sort", "descending");
expect(cellText(0, 0)).toBe("Item 3");
await expect.element(sortButton(screen, "Price")).toHaveFocus();
await press(" ");
await expect.element(header(screen, "Price")).toHaveAttribute("aria-sort", "none");
await expect.element(sortButton(screen, "Price")).toHaveFocus();On a header, Alt and Shift with an arrow to the side move its column's edge, and with an arrow up or down sort by it
const screen = await render(<Grid count={3} />);
await click(0, 0);
await press("{ArrowUp}{ArrowRight}");
await press("{Alt>}{Shift>}{ArrowRight}{ArrowRight}{ArrowLeft}{/Shift}{/Alt}");
expect(widthOf(1)).toBe(136);
expect(widthOf(0)).toBe(120);
await press("{Alt>}{Shift>}{ArrowDown}{/Shift}{/Alt}");
await expect.element(header(screen, "Price")).toHaveAttribute("aria-sort", "descending");
await expect.element(sortButton(screen, "Price")).toHaveFocus();Tab from a header goes on from the grid, which is one stop; Escape goes back to the active cell
const screen = await render(
<>
<Grid />
<input aria-label="After" />
</>,
);
await click(2, 1);
await press(`{${COMMAND}>}{ArrowUp}{/${COMMAND}}{ArrowUp}`);
await expect.element(sortButton(screen, "Price")).toHaveFocus();
// From another column's header, Escape is still back to the cell that was left.
await press("{ArrowRight}{Escape}");
expect(cellElement(0, 1).contains(document.activeElement)).toBe(true);
expect(active()).toBe("0,1");
await press("{ArrowUp}{Tab}");
await expect.element(screen.getByLabelText("After")).toHaveFocus();
await press("{Shift>}{Tab}{/Shift}");
// Back in at the active cell.
expect(cellElement(0, 1).contains(document.activeElement)).toBe(true);The Menu key, or Shift with F10, on a header opens the column's menu: sorting either way, fitting, and moving
const screen = await render(<Grid count={3} />);
await click(0, 1);
await press("{ArrowUp}{Shift>}{F10}{/Shift}");
await expect.poll(() => menuOpen(screen, "Price")).toBe(true);
expect(
Array.from(menu(screen, "Price").element().querySelectorAll('[role^="menuitem"]'), (element) => [
element.getAttribute("role"),
element.textContent,
]),
).toEqual([
["menuitemcheckbox", "Sort ascending"],
["menuitemcheckbox", "Sort descending"],
["menuitem", "Fit to contents"],
["menuitem", "Move before Name"],
["menuitem", "Move after Status"],
]);
await press("{ArrowDown}{Enter}");
await expect.element(header(screen, "Price")).toHaveAttribute("aria-sort", "descending");
// Closed, the menu gives focus back to the header it was asked for on.
await expect.element(sortButton(screen, "Price")).toHaveFocus();
await press("{ContextMenu}");
await expect.poll(() => menuOpen(screen, "Price")).toBe(true);
await expect
.element(screen.getByRole("menuitemcheckbox", { name: "Sort descending" }))
.toHaveAttribute("aria-checked", "true");Fit to contents, in the column's menu, fits the column to its name and to what its cells hold
const screen = await render(<Grid count={3} />);
await click(0, 3);
await press("{ArrowUp}{Shift>}{F10}{/Shift}");
await expect.poll(() => menuOpen(screen, "Released")).toBe(true);
await press("{ArrowDown}{ArrowDown}{Enter}");
await expect.poll(() => widthOf(3)).toBeLessThan(120);
expect(widthOf(3)).toBeGreaterThan(80);A secondary click on a column's header opens the column's menu, and leaves the selection as it isChromium, Firefox, WebKit
Not yet written.
A finger that rests on a column's header opens the column's menuChromium · touch, WebKit · touch
Not yet written.
A grid with no rows still lets the keyboard reach its headers: Tab reaches the grid, an arrow its first header, and Escape comes back
const screen = await render(<Grid count={0} readOnly />);
await userEvent.click(screen.getByLabelText("Before"));
await press("{Tab}");
expect(document.activeElement).toBe(gridElement(screen));
await press("{ArrowDown}");
await expect.element(sortButton(screen, "Name")).toHaveFocus();
// Along the headers, and sorting there, as with rows.
await press("{ArrowRight}{Enter}");
await expect.element(header(screen, "Price")).toHaveAttribute("aria-sort", "ascending");
await expect.element(sortButton(screen, "Price")).toHaveFocus();
await press("{Escape}");
expect(document.activeElement).toBe(gridElement(screen));Every header and cell states its column's place, and every row its place as it is shown, whatever is in view and however it is sorted
const screen = await render(<Grid count={1000} sort={{ key: "price", direction: "descending" }} />);
gridElement(screen).scrollTop = 500 * ROW;
await expect.poll(() => document.querySelector('[role="gridcell"][data-row="500"]')).not.toBeNull();
const row = cellElement(500, 2).parentElement!;
// The header is the first row, so the row shown 501st is the 502nd.
expect(row.getAttribute("aria-rowindex")).toBe("502");
// The record shown there is the 500th from the dearest.
expect(cellText(500, 0)).toBe("Item 500");
// The row headers are the first column.
expect(cellElement(500, 2).getAttribute("aria-colindex")).toBe("4");
await expect.element(header(screen, "Status")).toHaveAttribute("aria-colindex", "4");
await expect.element(header(screen, "Price")).toHaveAttribute("aria-sort", "descending");
await expect.element(header(screen, "Status")).toHaveAttribute("aria-sort", "none");Moving columns
A header dragged past the middle of its neighbour takes its column there, and not beforeChromium, Firefox, WebKit
Not yet written.
Dragging a header selects nothing, and pressing one without dragging still selects its columnChromium, Firefox, WebKit
Not yet written.
The active cell and the selection go with their columnsChromium, Firefox, WebKit
Not yet written.
While a header is dragged a line shows where its column would land; Escape takes the drag backChromium, Firefox, WebKit
Not yet written.
Where the system forces its colours, the header being dragged and the line where it would land take the highlightChromium
Not yet written.
On a header, Alt with an arrow to the side moves the column that way; focus goes with it, and where it now is is said
const screen = await render(<Grid />);
await click(0, 0);
await press("{ArrowUp}{Alt>}{ArrowRight}{/Alt}");
expect(headers(screen)).toEqual(["Price", "Name", "Status", "Released", "Featured"]);
await expect.element(sortButton(screen, "Name")).toHaveFocus();
await expect.element(status(screen)).toHaveTextContent("Name moved to column 2 of 5.");
await press("{Alt>}{ArrowLeft}{ArrowLeft}{/Alt}");
// Back to the first place, and no further.
expect(headers(screen)[0]).toBe("Name");
await expect.element(sortButton(screen, "Name")).toHaveFocus();
// The active cell went with its column, and came back with it.
expect(active()).toBe("0,0");Right to left, a column moves the way its arrow points
const screen = await render(
<div dir="rtl">
<Grid />
</div>,
);
await click(0, 0);
// The columns run from the right: leftward is onward.
await press("{ArrowUp}{Alt>}{ArrowLeft}{/Alt}");
expect(headers(screen).slice(0, 2)).toEqual(["Price", "Name"]);
await press("{ArrowLeft}");
await expect.element(sortButton(screen, "Status")).toHaveFocus();A header held at the edge of the view scrolls the columns to it, and its column lands where it is let goChromium, Firefox, WebKit
Not yet written.
However long a frame takes, the columns scroll as far in the same time: a slow frame scrolls furtherChromium, Firefox, WebKit
Not yet written.
Right to left, a header dragged leftward moves onwardChromium, Firefox, WebKit
Not yet written.
The column's menu moves it before the column before it, or after the one after; the first column has none before
const screen = await render(<Grid />);
await click(0, 0);
await press("{ArrowUp}{Shift>}{F10}{/Shift}");
const move = screen.getByRole("menuitem", { name: "Move after Price" });
await expect.element(move).toBeVisible();
expect(screen.getByRole("menuitem", { name: /Move before/ }).query()).toBeNull();
await settled();
if (hasTouch) await commands.tap(move.selector);
else await userEvent.click(move);
await expect.poll(() => headers(screen).slice(0, 2)).toEqual(["Price", "Name"]);What was done before a column was moved is still undone in the cell it was done to
const screen = await render(<Grid />);
await click(1, 0);
await press("Kettle{Enter}{ArrowUp}{ArrowUp}{ArrowUp}{Alt>}{ArrowRight}{/Alt}{ArrowDown}");
expect(headers(screen).slice(0, 2)).toEqual(["Price", "Name"]);
expect(cellText(1, 1)).toBe("Kettle");
await press(`{${COMMAND}>}z{/${COMMAND}}`);
expect(cellText(1, 1)).toBe("Item 2");
expect(active()).toBe("1,1");Columns that are only shown
A cell of a field that is read-only says so, and typing, Enter and a double click do not edit it
const onChange = vi.fn();
await render(<Stock onChange={onChange} />);
await click(0, 0);
expect(cellElement(0, 0).getAttribute("aria-readonly")).toBe("true");
expect(cellElement(0, 1).getAttribute("aria-readonly")).toBeNull();
await press("x{Enter}y");
await commands.clickSelector('[data-row="0"][data-column="0"]', { position: { x: 40, y: 6 } });
expect(document.querySelector("[data-editing]")).toBeNull();
expect(document.activeElement).toBe(cellElement(0, 0));
expect(onChange).not.toHaveBeenCalled();Clearing, pasting and a click on its checkbox pass over such a column, and change the others
const onChange = vi.fn();
const screen = await render(<Stock onChange={onChange} />);
await click(0, 0);
await press("{Shift>}{ArrowRight}{ArrowRight}{/Shift}{Delete}");
expect(onChange.mock.lastCall![0][0]).toEqual({ sku: "A-1", count: null, listed: true });
if (scriptedClipboard) {
clipboard(screen, "paste", "Z-9\t8\tno");
expect(onChange.mock.lastCall![0][0]).toEqual({ sku: "A-1", count: 8, listed: true });
}
await userEvent.click(cellElement(1, 2).querySelector("input")!);
expect(onChange.mock.lastCall![0][1]).toEqual({ sku: "B-2", count: 5, listed: false });Sorting
A column's header has a button that sorts by it: ascending, then descending, then not at all
const screen = await render(<Grid count={3} />);
await userEvent.click(sortButton(screen, "Price"));
expect(names(3)).toEqual(["Item 1", "Item 2", "Item 3"]);
await expect
.element(screen.getByRole("columnheader", { name: "Price" }))
.toHaveAttribute("aria-sort", "ascending");
await userEvent.click(sortButton(screen, "Price"));
expect(names(3)).toEqual(["Item 3", "Item 2", "Item 1"]);
await expect
.element(screen.getByRole("columnheader", { name: "Price" }))
.toHaveAttribute("aria-sort", "descending");
await expect.element(status(screen)).toHaveTextContent("Sorted by Price, descending.");
await userEvent.click(sortButton(screen, "Price"));
expect(names(3)).toEqual(["Item 1", "Item 2", "Item 3"]);
await expect
.element(screen.getByRole("columnheader", { name: "Price" }))
.toHaveAttribute("aria-sort", "none");Sorting shows the records in another order, and leaves them as they are kept
const onChange = vi.fn();
await render(<Grid count={3} defaultSort={byPriceDescending} onChange={onChange} />);
expect(names(3)).toEqual(["Item 3", "Item 2", "Item 1"]);
expect(onChange).not.toHaveBeenCalled();An edit made while sorted changes the record that is shown there
const onChange = vi.fn();
await render(<Grid count={3} defaultSort={byPriceDescending} onChange={onChange} />);
await click(0, 0);
await press("Kettle{Enter}");
const rows = onChange.mock.lastCall![0] as Product[];
expect(rows.map((row) => row.name)).toEqual(["Item 1", "Item 2", "Kettle"]);A row does not move when the value it is sorted by is changed, until it is sorted again
const screen = await render(<Grid count={3} defaultSort={byPriceDescending} />);
await click(0, 1);
await press("1{Enter}");
expect(names(3)).toEqual(["Item 3", "Item 2", "Item 1"]);
expect(cellText(0, 1)).toBe("1");
await userEvent.click(sortButton(screen, "Price"));
await userEvent.click(sortButton(screen, "Price"));
await userEvent.click(sortButton(screen, "Price"));
expect(names(3)).toEqual(["Item 2", "Item 1", "Item 3"]);Sorting keeps the active cell on its record
const screen = await render(<Grid count={4} />);
await click(0, 0);
await userEvent.click(sortButton(screen, "Price"));
await userEvent.click(sortButton(screen, "Price"));
expect(active()).toBe("3,0");
expect(cellText(3, 0)).toBe("Item 1");Alt and Shift with the up arrow sort by the active cell's column ascending, with the down arrow descending, and the same again not at all
await render(<Grid count={3} />);
await click(1, 1);
await press("{Alt>}{Shift>}{ArrowDown}{/Shift}{/Alt}");
expect(names(3)).toEqual(["Item 3", "Item 2", "Item 1"]);
await press("{Alt>}{Shift>}{ArrowUp}{/Shift}{/Alt}");
expect(names(3)).toEqual(["Item 1", "Item 2", "Item 3"]);
await press("{Alt>}{Shift>}{ArrowUp}{/Shift}{/Alt}");
expect(document.querySelector('[aria-sort="ascending"]')).toBeNull();A record with nothing in the column sorted by comes last, either way
const screen = await render(<Grid count={3} />);
// Only the first record has a date.
await userEvent.click(sortButton(screen, "Released"));
expect(names(3)[0]).toBe("Item 1");
await userEvent.click(sortButton(screen, "Released"));
expect(names(3)[0]).toBe("Item 1");A record typed into the last row stays there, whatever it holds
const onChange = vi.fn();
await render(
<Grid count={2} defaultSort={{ key: "price", direction: "ascending" }} onChange={onChange} />,
);
await click(2, 1);
await press("1{Enter}");
expect(onChange.mock.lastCall![0]).toHaveLength(3);
expect(cellText(2, 1)).toBe("1");Removing rows while sorted removes the records shown in them, and undo puts them back where they were shown
const onChange = vi.fn();
await render(<Grid count={3} defaultSort={byPriceDescending} onChange={onChange} />);
await click(0, 0);
await press("{Shift>} {/Shift}");
await press(`{${COMMAND}>}{Backspace}{/${COMMAND}}`);
expect((onChange.mock.lastCall![0] as Product[]).map((row) => row.name)).toEqual(["Item 1", "Item 2"]);
await expect.poll(() => names(2)).toEqual(["Item 2", "Item 1"]);
await press(`{${COMMAND}>}z{/${COMMAND}}`);
await expect.poll(() => names(3)).toEqual(["Item 3", "Item 2", "Item 1"]);
expect(selected()).toEqual(["0,0", "0,1", "0,2", "0,3", "0,4"]);Rows replaced by their owner are sorted afresh
const screen = await render(<Grid count={5} defaultSort={byPriceDescending} />);
expect(names(2)).toEqual(["Item 5", "Item 4"]);
(screen.getByRole("button", { name: "Load others" }).element() as HTMLElement).click();
await expect.poll(() => names(2)).toEqual(["Item 2", "Item 1"]);Copying while sorted copies the rows as they are shown
const screen = await render(<Grid count={3} defaultSort={byPriceDescending} />);
await click(0, 0);
await press("{Shift>}{ArrowDown}{/Shift}");
const data = new DataTransfer();
gridElement(screen).dispatchEvent(
new ClipboardEvent("copy", { clipboardData: data, bubbles: true, cancelable: true }),
);
if (scriptedClipboard) expect(data.getData("text/plain")).toBe("Item 3\nItem 2");Computed and locked cells
A computed column follows what it is computed from: an edit goes out with every value it moves, as one change
const onCellsChange = vi.fn();
await render(<Lines onCellsChange={onCellsChange} />);
await click(0, 2);
await press("3{Enter}");
expect(onCellsChange).toHaveBeenCalledTimes(1);
expect(onCellsChange).toHaveBeenCalledWith([
{ row: 0, key: "quantity", value: 3, previous: 2 },
{ row: 0, key: "amount", value: 30, previous: 20 },
]);
await expect.poll(() => cellText(0, 3)).toBe("30");A computed column's header says it is computed, and as what
const screen = await render(<Lines />);
const amount = screen.getByRole("columnheader", { name: "Amount" });
await expect.element(amount).toHaveAccessibleDescription("Computed as price * quantity.");
// And so does what it takes focus on.
await expect
.element(screen.getByRole("button", { name: "Sort by Amount" }))
.toHaveAccessibleDescription("Computed as price * quantity.");
await expect.element(amount).toHaveAttribute("data-computed");
expect(amount.element().querySelector("[data-computed-mark]")?.textContent).toContain("computed");
await expect
.element(screen.getByRole("columnheader", { name: "Price" }))
.not.toHaveAttribute("data-computed");A computed cell is marked, and says what it is computed as, which assistive technology hears with it
await render(<Lines />);
await click(0, 3);
await expect.element(focused()).toHaveAccessibleDescription("Computed as price * quantity.");
expect(cellElement(0, 3).hasAttribute("data-computed")).toBe(true);
expect(cellElement(0, 1).hasAttribute("data-computed")).toBe(false);
expect(cellElement(1, 3).title).toBe("Computed as price * quantity.");Typing over a computed cell freezes it at what was typed: it is marked and says so, and no longer follows
const onChange = vi.fn();
const onLocksChange = vi.fn();
await render(<Lines onChange={onChange} onLocksChange={onLocksChange} />);
await click(0, 3);
await press("100{Enter}");
expect(rowsOf(onChange)[0]).toEqual({ item: "Teapot", price: 10, quantity: 2, amount: 100 });
expect(onLocksChange).toHaveBeenLastCalledWith(new Map([[lockKey("0", "amount"), "frozen"]]));
expect(lockOn(0, 3)).toBe("frozen");
expect(cellElement(0, 3).querySelector('[data-lock-mark="frozen"]')).not.toBeNull();
await click(0, 3);
await expect
.element(focused())
.toHaveAccessibleDescription("Frozen: kept as it is. Computed as price * quantity.");
await click(0, 2);
await press("5{Enter}");
expect(rowsOf(onChange)[0]).toEqual({ item: "Teapot", price: 10, quantity: 5, amount: 100 });A held cell cannot be changed, is marked, and says why — when typed at, aloud
const onChange = vi.fn();
const screen = await render(<Lines onChange={onChange} defaultLocks={held("0", "price")} />);
await click(0, 1);
expect(lockOn(0, 1)).toBe("held");
expect(cellElement(0, 1).getAttribute("aria-readonly")).toBe("true");
expect(cellElement(0, 1).querySelector('[data-lock-mark="held"]')).not.toBeNull();
await expect.element(focused()).toHaveAccessibleDescription("Held: it may not change.");
await press("7{Enter}");
expect(document.querySelector("[data-editing]")).toBeNull();
expect(onChange).not.toHaveBeenCalled();
await expect.element(status(screen)).toHaveTextContent("Price: Held: it may not change.");An edit that would move a held value is not made: the cell keeps what was typed, marked, with why, said aloud; Escape puts it aside
const onChange = vi.fn();
const screen = await render(<Lines onChange={onChange} defaultLocks={held("0", "amount")} />);
await click(0, 2);
await press("5{Enter}");
expect(onChange).not.toHaveBeenCalled();
const field = cellElement(0, 2).querySelector("input")!;
expect(field.value).toBe("5");
expect(field.getAttribute("aria-invalid")).toBe("true");
expect(cellElement(0, 2).hasAttribute("data-refused")).toBe(true);
await expect.element(field).toHaveAccessibleDescription("Amount is held; this would change it.");
await expect
.element(status(screen))
.toHaveTextContent("Quantity: Amount is held; this would change it.");
// Nor is it left by Tab or an arrow, as text that cannot be read is not.
await press("{Tab}");
expect(active()).toBe("0,2");
await press("{Escape}");
expect(document.querySelector("[data-editing]")).toBeNull();
expect(cellText(0, 2)).toBe("2");
expect(cellElement(0, 2).hasAttribute("data-refused")).toBe(false);Enter asks for a refused edit again, which a lock taken off since lets through
const onChange = vi.fn();
function Owned() {
const [locks, setLocks] = useState<ReadonlyMap<string, Lock>>(held("0", "amount"));
return (
<>
<button type="button" onClick={() => setLocks(new Map())}>
Unlock all
</button>
<Lines onChange={onChange} locks={locks} onLocksChange={setLocks} />
</>
);
}
const screen = await render(<Owned />);
await click(0, 2);
await press("5{Enter}");
expect(onChange).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "Unlock all" }));
// What was typed stayed, for the person to come back to.
const field = cellElement(0, 2).querySelector("input")!;
expect(field.value).toBe("5");
await userEvent.click(field);
await press("{Enter}");
expect(rowsOf(onChange)[0]).toEqual({ item: "Teapot", price: 10, quantity: 5, amount: 50 });
expect(document.querySelector("[data-refused]")).toBeNull();A paste over locked cells does what it may and leaves the rest, and says once how many were refused and by whatChromium, Chromium · touch, WebKit, WebKit · touch
Not yet written.
Clearing and filling over locked cells leave them in the same way, and say so
const onChange = vi.fn();
const screen = await render(<Lines onChange={onChange} defaultLocks={held("1", "price")} />);
await click(0, 1);
await press(`{Shift>}{ArrowDown}{ArrowDown}{/Shift}{${COMMAND}>}d{/${COMMAND}}`);
expect(prices(rowsOf(onChange))).toEqual([10, 4, 10]);
expect(rowsOf(onChange)[2]!.amount).toBe(10);
await expect
.element(status(screen))
.toHaveTextContent("Filled 1 cell. 1 cell was refused by locks on Price.");
await press("{Delete}");
expect(prices(rowsOf(onChange))).toEqual([null, 4, null]);
await expect.element(status(screen)).toHaveTextContent("1 cell was refused by locks on Price.");A computed cell written with what it would come to anyway is not frozen: a row pasted whole goes on followingChromium, Chromium · touch, WebKit, WebKit · touch
Not yet written.
One undo takes back an edit with everything it computed and the lock it made; redo does them again
const onChange = vi.fn();
const onLocksChange = vi.fn();
await render(<Lines onChange={onChange} onLocksChange={onLocksChange} />);
await click(0, 3);
await press("100{Enter}");
await press(`{${COMMAND}>}z{/${COMMAND}}`);
expect(rowsOf(onChange)[0]!.amount).toBe(20);
expect(onLocksChange).toHaveBeenLastCalledWith(new Map());
await expect.poll(() => lockOn(0, 3)).toBeNull();
await press(`{${COMMAND}>}{Shift>}z{/Shift}{/${COMMAND}}`);
expect(rowsOf(onChange)[0]!.amount).toBe(100);
await expect.poll(() => lockOn(0, 3)).toBe("frozen");The cells' menu has the lock of a computed cell: Hold value and Freeze; for a frozen one, Hold value and Unfreeze; for a held one, Unlock
const screen = await render(
<Lines
defaultLocks={
new Map<string, Lock>([
[lockKey("1", "amount"), "frozen"],
[lockKey("2", "amount"), "held"],
])
}
/>,
);
await click(0, 3);
await ask(screen);
expect(lockItems(screen)).toEqual(["Hold value", "Freeze"]);
await press("{Escape}");
await click(1, 3);
await ask(screen);
expect(lockItems(screen)).toEqual(["Hold value", "Unfreeze"]);
await press("{Escape}");
await click(2, 3);
await ask(screen);
expect(lockItems(screen)).toEqual(["Unlock"]);Where the owner does not work with locks, only a computed cell's menu has them; where it does, every cell's
const screen = await render(<Lines />);
await click(0, 1);
await ask(screen);
expect(lockItems(screen)).toEqual([]);
await screen.unmount();
const owned = await render(<Lines onLocksChange={() => {}} />);
await click(0, 1);
await ask(owned);
expect(lockItems(owned)).toEqual(["Hold value"]);A finger that rests on a computed cell opens the menu with its lockChromium · touch, WebKit · touch
Not yet written.
Freeze keeps a computed cell as it is; Unfreeze computes it again, as one change, and one undo freezes it again
const onCellsChange = vi.fn();
const screen = await render(<Lines onCellsChange={onCellsChange} />);
await click(0, 3);
await ask(screen);
await choose(screen, "Freeze");
expect(lockOn(0, 3)).toBe("frozen");
expect(onCellsChange).not.toHaveBeenCalled();
await click(0, 2);
await press("5{Enter}");
expect(onCellsChange).toHaveBeenLastCalledWith([{ row: 0, key: "quantity", value: 5, previous: 2 }]);
await click(0, 3);
await ask(screen);
await choose(screen, "Unfreeze");
expect(onCellsChange).toHaveBeenLastCalledWith([{ row: 0, key: "amount", value: 50, previous: 20 }]);
await expect.poll(() => lockOn(0, 3)).toBeNull();
await press(`{${COMMAND}>}z{/${COMMAND}}`);
expect(onCellsChange).toHaveBeenLastCalledWith([{ row: 0, key: "amount", value: 20, previous: 50 }]);
await expect.poll(() => lockOn(0, 3)).toBe("frozen");On a range, the menu holds, freezes or unlocks every cell it applies to, and one undo takes it back
const screen = await render(<Lines onLocksChange={() => {}} />);
await click(0, 2);
await press("{Shift>}{ArrowDown}{ArrowRight}{/Shift}");
await ask(screen);
expect(lockItems(screen)).toEqual(["Hold value", "Freeze"]);
await choose(screen, "Freeze");
// Only what is computed is frozen.
expect([lockOn(0, 2), lockOn(0, 3), lockOn(1, 2), lockOn(1, 3)]).toEqual([
null,
"frozen",
null,
"frozen",
]);
await ask(screen);
await choose(screen, "Hold value");
expect([lockOn(0, 2), lockOn(0, 3), lockOn(1, 2), lockOn(1, 3)]).toEqual([
"held",
"held",
"held",
"held",
]);
await ask(screen);
expect(lockItems(screen)).toEqual(["Unlock"]);
await press("{Escape}");
await press(`{${COMMAND}>}z{/${COMMAND}}`);
await expect
.poll(() => [lockOn(0, 2), lockOn(0, 3), lockOn(1, 2), lockOn(1, 3)])
.toEqual([null, "frozen", null, "frozen"]);Unfreezing that would move a held value is refused, and says so
const chained = schema({
kind: "object",
fields: [
{ key: "price", schema: { kind: "number", label: "Price" } },
{ key: "total", schema: { kind: "number", label: "Total", formula: "price * 2" } },
{ key: "doubled", schema: { kind: "number", label: "Doubled", formula: "total * 2" } },
],
});
const onChange = vi.fn();
const screen = await render(
<DataGrid
aria-label="Products"
schema={chained}
rows={[{ price: 1, total: 100, doubled: 200 }]}
onRowsChange={onChange}
defaultLocks={
new Map<string, Lock>([
[lockKey("0", "total"), "frozen"],
[lockKey("0", "doubled"), "held"],
])
}
style={{ height: 240, width: 480 }}
/>,
);
await click(0, 1);
await ask(screen);
await choose(screen, "Unfreeze");
expect(onChange).not.toHaveBeenCalled();
expect(lockOn(0, 1)).toBe("frozen");
await expect.element(status(screen)).toHaveTextContent("Total: Doubled is held; this would change it.");Locks go with their records when the rows are sorted, records are put in and records are taken out
const onLocksChange = vi.fn();
const screen = await render(<Lines defaultLocks={held("2", "price")} onLocksChange={onLocksChange} />);
// Tray, the third record, is shown second when sorted by price, descending.
await userEvent.click(screen.getByRole("button", { name: "Sort by Price" }));
await userEvent.click(screen.getByRole("button", { name: "Sort by Price" }));
await expect.poll(() => lockOn(1, 1)).toBe("held");
await userEvent.click(screen.getByRole("button", { name: "Sort by Price" }));
await click(0, 0);
await ask(screen);
await choose(screen, "Insert row above");
expect(onLocksChange).toHaveBeenLastCalledWith(held("3", "price"));
await expect.poll(() => lockOn(3, 1)).toBe("held");
await press(`{${COMMAND}>}{Backspace}{/${COMMAND}}`);
expect(onLocksChange).toHaveBeenLastCalledWith(held("2", "price"));
await expect.poll(() => lockOn(2, 1)).toBe("held");Records known by a key of their own keep their locks whatever their owner does to the rows
const screen = await render(
<Lines rowKey={(row) => String(row.item)} defaultLocks={held("Tray", "price")} />,
);
expect(lockOn(2, 1)).toBe("held");
await userEvent.click(screen.getByRole("button", { name: "Reverse" }));
await expect.poll(() => lockOn(0, 1)).toBe("held");
expect(lockOn(2, 1)).toBeNull();A record made in the row that waits computes what it computes; typed over there, a computed cell is frozen in it
const onChange = vi.fn();
const onLocksChange = vi.fn();
const screen = await render(<Lines onChange={onChange} onLocksChange={onLocksChange} />);
await click(3, 3);
await press("7{Enter}");
expect(rowsOf(onChange)[3]).toMatchObject({ amount: 7 });
expect(onLocksChange).toHaveBeenLastCalledWith(new Map([[lockKey("3", "amount"), "frozen"]]));
if (scriptedClipboard) {
await click(4, 0);
clipboard(screen, "paste", "Bowl\t3\t4");
expect(rowsOf(onChange)[4]).toEqual({ item: "Bowl", price: 3, quantity: 4, amount: 12 });
}Rows are shown as they are given: what they compute is made right where an edit reaches it
const onChange = vi.fn();
await render(
<Lines onChange={onChange} initial={[{ item: "Teapot", price: 10, quantity: 2, amount: 99 }]} />,
);
expect(cellText(0, 3)).toBe("99");
await click(0, 0);
await press("Kettle{Enter}");
expect(rowsOf(onChange)[0]!.amount).toBe(99);
await click(0, 1);
await press("11{Enter}");
expect(rowsOf(onChange)[0]!.amount).toBe(22);A formula that cannot be used is said on its column's header, which is marked, and its cells are not computed
const broken = schema({
kind: "object",
fields: [
{ key: "price", schema: { kind: "number", label: "Price" } },
{ key: "total", schema: { kind: "number", label: "Total", formula: "price * cost" } },
],
});
const screen = await render(
<DataGrid
aria-label="Products"
schema={broken}
rows={[{ price: 1, total: 2 }]}
onRowsChange={() => {}}
style={{ height: 240, width: 480 }}
/>,
);
const total = screen.getByRole("columnheader", { name: "Total" });
await expect.element(total).toHaveAccessibleDescription("There is no field “cost”.");
await expect.element(total).toHaveAttribute("data-formula-problem");
expect(total.element().querySelector("[data-problem-mark]")).not.toBeNull();
expect(cellElement(0, 1).hasAttribute("data-computed")).toBe(false);Has no accessibility violations with computed, frozen, held and refused cells
const screen = await render(
<Lines
defaultLocks={
new Map<string, Lock>([
[lockKey("0", "amount"), "held"],
[lockKey("1", "amount"), "frozen"],
[lockKey("2", "price"), "held"],
])
}
/>,
);
await click(0, 2);
await press("5{Enter}");
expect(cellElement(0, 2).hasAttribute("data-refused")).toBe(true);
await expectAccessible(screen.container);Columns: reordering and filtering by kind
React
Install
npx shadcn@latest add https://ui.publictheta.com/r/data-grid.jsonSource
src/components/data-grid/data-grid.tsx
"use client";
import { memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import type {
ClipboardEvent,
ComponentProps,
KeyboardEvent,
MouseEvent,
PointerEvent,
ReactElement,
} from "react";
import { flushSync } from "react-dom";
import { formatTSV, parseTSV } from "../../algorithms/tsv.ts";
import { useLocale } from "../../lib/locale.ts";
import { mergeProps } from "../../lib/merge-props.ts";
import { mergeRefs } from "../../lib/merge-refs.ts";
import { isCommandKey } from "../../lib/platform.ts";
import { useCoarsePointer } from "../../lib/pointer.ts";
import { cn } from "../../lib/utils.ts";
import { useAnnouncer } from "../../primitives/announcements.ts";
import { continuation, fillFromFirst, fillSeries, fillTarget } from "../../primitives/fill.ts";
import type { FillKind, FillTarget } from "../../primitives/fill.ts";
import { applyGridMove, gridMoveForKey } from "../../primitives/grid-navigation.ts";
import type { GridMove } from "../../primitives/grid-navigation.ts";
import {
activeCell,
addCell,
boundsOf,
clampSelection,
clipboardBounds,
dragCorner,
extendTo,
focusCell,
inSpans,
isCellSelected,
rowSpans,
selectAll,
selectCell,
selectRows,
} from "../../primitives/grid-selection.ts";
import type { Bounds, Cell, Corner, GridSelection } from "../../primitives/grid-selection.ts";
import {
LOCK_MESSAGES,
deriveRecord,
derivationsOf,
editRecords,
lockKey,
locksAfter,
unlockRecord,
} from "../../primitives/derived.ts";
import type { Lock, LockMessages, Refusal } from "../../primitives/derived.ts";
import { createHistory } from "../../primitives/history.ts";
import { useControllableState } from "../../primitives/use-controllable-state.ts";
import { compareFor, orderAfter, placesIn, sortedOrder } from "../../primitives/view-order.ts";
import type { ViewOrder } from "../../primitives/view-order.ts";
import { arrange, landingPlace, moveItem } from "../../primitives/reorder.ts";
import { applyChange, invertChange, runsOf } from "../../primitives/record-changes.ts";
import type { CellChange, RecordChange, Run } from "../../primitives/record-changes.ts";
import { textCodec } from "../../primitives/schema-text.ts";
import type { TextCodec } from "../../primitives/schema-text.ts";
import { blank, resolve, validate } from "../../primitives/schema.ts";
import type { Issue, ObjectSchema, Schema } from "../../primitives/schema.ts";
import {
fixedWindow,
listOffset,
scrollOffsetFor,
scrollSize,
scrollToReveal,
} from "../../algorithms/virtual.ts";
import { Checkbox } from "../checkbox/checkbox.tsx";
import {
ContextMenu,
ContextMenuTrigger,
MenuCheckboxItem,
MenuContent,
MenuItem,
MenuSeparator,
} from "../menu/menu.tsx";
import { Select, SelectItem } from "../select/select.tsx";
type Row = Readonly<Record<string, unknown>>;
export type { CellChange, Lock, Run };
/** The width of the column of row headers, in pixels. */
const HEADER = 48;
/** The least and the most a column can be made, in pixels, and how far a key moves its edge. */
const NARROWEST = 48;
const WIDEST = 960;
const NUDGE = 16;
const NO_WIDTHS: Readonly<Record<string, number>> = Object.freeze({});
const NO_ORDER: readonly string[] = Object.freeze([]);
/** How far a header is dragged before it is being moved, not pressed. */
const DRAG_SLOP = 4;
/** How near an edge of the view a header held there scrolls the columns. */
const COLUMN_EDGE = 24;
const clampWidth = (width: number) => Math.min(Math.max(Math.round(width), NARROWEST), WIDEST);
/** What the grid says in words; pass `messages` to say it in another language. */
export interface DataGridMessages extends LockMessages {
/** Why what was typed is not a value, by the reason its kind gives; `invalid` stands for any other. */
readonly unreadable: Readonly<Record<string, string>>;
/** Why a value a cell holds is not one its field allows, by the schema's code for it. */
readonly issue: Readonly<Record<string, string>>;
/** What a paste did: how many cells took a value, and how many were left because their text could not be read. */
readonly pasted: (cells: number, unread: number) => string;
readonly undone: string;
readonly redone: string;
/** What removing rows did. */
readonly removed: (rows: number) => string;
/** The name of the corner that selects every cell, and of the row that waits to become a new record. */
readonly selectAll: string;
readonly newRow: string;
/** The name of the edge that resizes a column, given the column's name. */
readonly resize: (column: string) => string;
/** The name of the button that sorts by a column, and what sorting did. */
readonly sortBy: (column: string) => string;
readonly sorted: (column: string, direction: "ascending" | "descending" | null) => string;
/** The name of the menu of what can be done with the selected cells, and of each thing in it. */
readonly menu: string;
readonly cut: string;
readonly copy: string;
readonly paste: string;
readonly clear: string;
readonly fillDown: string;
readonly fillAcross: string;
/** Continuing the series the values at the selection's start make, through the rest of it. */
readonly fillSeries: string;
readonly insertRows: (rows: number) => string;
readonly removeRows: (rows: number) => string;
/** What copying from the menu did, and what is said where the clipboard cannot be written or read from a menu. */
readonly copied: (cells: number) => string;
readonly copyDenied: string;
readonly pasteDenied: string;
/** What a fill did: how many cells it filled, how many it left because they are read-only, and how many because they could not take the value. */
readonly filled: (cells: number, fixed: number, unread: number) => string;
readonly inserted: (rows: number) => string;
/** The name of the menu of what can be done with a column, given the column's name, and of each thing in it. */
readonly columnMenu: (column: string) => string;
readonly sortAscending: string;
readonly sortDescending: string;
readonly fit: string;
/** Moving a column to the place before, or after, the column named. */
readonly moveBefore: (column: string) => string;
readonly moveAfter: (column: string) => string;
/** What moving a column did: its name, its place now, and how many columns there are. */
readonly moved: (column: string, place: number, columns: number) => string;
/** A problem as it is announced, with the name of the column it is in. */
readonly problem: (column: string, problem: string) => string;
/** How many cells locks refused, and the columns whose locks refused them. */
readonly refusedBy: (cells: number, columns: readonly string[]) => string;
}
const counted = (number: number, one: string, many: string) => `${number} ${number === 1 ? one : many}`;
const MESSAGES: DataGridMessages = {
...LOCK_MESSAGES,
unreadable: {
invalid: "Not a value this column takes.",
nonexistent: "There is no such date.",
minimum: "Below the least this column allows.",
maximum: "Above the most this column allows.",
minLength: "Too short.",
maxLength: "Too long.",
pattern: "Not in the form this column takes.",
},
issue: {
required: "Required.",
type: "Not a value this column takes.",
minimum: "Below the least this column allows.",
maximum: "Above the most this column allows.",
minLength: "Too short.",
maxLength: "Too long.",
pattern: "Not in the form this column takes.",
option: "Not one of the choices.",
unique: "Already used.",
},
pasted: (cells, unread) =>
unread === 0
? `Pasted ${cells} ${cells === 1 ? "cell" : "cells"}.`
: `Pasted ${cells} ${cells === 1 ? "cell" : "cells"}; ${unread} could not be read and ${unread === 1 ? "was" : "were"} left as ${unread === 1 ? "it was" : "they were"}.`,
undone: "Undone.",
redone: "Redone.",
removed: (rows) => `Removed ${rows} ${rows === 1 ? "row" : "rows"}.`,
selectAll: "Select all",
newRow: "New row",
resize: (column) => `Resize ${column}`,
sortBy: (column) => `Sort by ${column}`,
sorted: (column, direction) =>
direction === null ? "No longer sorted." : `Sorted by ${column}, ${direction}.`,
menu: "Cells",
cut: "Cut",
copy: "Copy",
paste: "Paste",
clear: "Clear",
// Named for the way the columns run, which is not rightward everywhere.
fillDown: "Fill down",
fillAcross: "Fill across",
fillSeries: "Fill series",
insertRows: (rows) => (rows === 1 ? "Insert row above" : `Insert ${rows} rows above`),
removeRows: (rows) => (rows === 1 ? "Delete row" : `Delete ${rows} rows`),
copied: (cells) => `Copied ${counted(cells, "cell", "cells")}.`,
copyDenied: "The clipboard could not be written to from here. Copy with the keyboard instead.",
pasteDenied: "The clipboard could not be read from here. Paste with the keyboard instead.",
filled: (cells, fixed, unread) =>
`Filled ${counted(cells, "cell", "cells")}${fixed === 0 ? "" : `; ${counted(fixed, "is", "are")} read-only and ${fixed === 1 ? "was" : "were"} left`}${unread === 0 ? "" : `; ${unread} could not take the value and ${unread === 1 ? "was" : "were"} left`}.`,
inserted: (rows) => `Inserted ${counted(rows, "row", "rows")}.`,
columnMenu: (column) => `${column} column`,
sortAscending: "Sort ascending",
sortDescending: "Sort descending",
fit: "Fit to contents",
moveBefore: (column) => `Move before ${column}`,
moveAfter: (column) => `Move after ${column}`,
moved: (column, place, columns) => `${column} moved to column ${place} of ${columns}.`,
problem: (column, problem) => `${column}: ${problem}`,
refusedBy: (cells, columns) =>
`${counted(cells, "cell was", "cells were")} refused by locks on ${new Intl.ListFormat("en", { type: "conjunction" }).format(columns)}.`,
};
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== "";
/** The keys that go on from a grid with no cells to its headers. */
const TO_HEADERS = new Set(["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Home", "End"]);
/** A frame at the pace a drag's scrolling is measured in: sixty a second. */
const FRAME = 1000 / 60;
/** How far inside an edge a finger's drag begins to scroll: a finger cannot always go beyond it. */
const TOUCH_EDGE = 28;
/** The least a finger can be asked to hit, in pixels. */
const TOUCH_TARGET = 44;
/** Which column the rows are shown in the order of, and which way. */
export interface GridSort {
/** The field's key. */
readonly key: string;
readonly direction: "ascending" | "descending";
}
export type DataGridProps = Omit<ComponentProps<"div">, "onChange"> & {
/** The record each row holds; its scalar fields are the columns. */
schema: ObjectSchema;
rows: readonly Row[];
/** Hears every edit, with the rows as they now are. */
onRowsChange?: (rows: Row[]) => void;
/**
* Hears every edit as the cells it changed — for rows too many to copy on
* each keystroke, or kept somewhere that is not an array. Without this or
* `onRowsChange`, the grid only shows.
*/
onCellsChange?: (changes: readonly CellChange[]) => void;
/** Hears records being added: where, and which. With this or `onRowsChange`, the grid ends in a row that becomes a new record when it is typed in. */
onRowsInsert?: (at: number, rows: readonly Row[]) => void;
/** Hears records being removed, in runs, each at its place before any was taken. */
onRowsRemove?: (runs: ReadonlyArray<Run<Row>>) => void;
/** The column the rows are shown in the order of, or `null` for the order they are kept in. The records themselves are not reordered. */
sort?: GridSort | null;
defaultSort?: GridSort | null;
onSortChange?: (sort: GridSort | null) => void;
/** Whether each row begins with a header that numbers it and selects it. Shown unless turned off. */
rowHeaders?: boolean;
locale?: string;
/** The width of a column that has been given none of its own, in pixels. */
columnWidth?: number;
/** The width of each column that has one of its own, by its field's key, in pixels. */
columnWidths?: Readonly<Record<string, number>>;
defaultColumnWidths?: Readonly<Record<string, number>>;
/** Hears a column's width being changed, with every width that has been. */
onColumnWidthsChange?: (widths: Readonly<Record<string, number>>) => void;
/**
* The order the columns are shown in, as their fields' keys. A field it does not
* name comes after those it does, in the schema's order.
*/
columnOrder?: readonly string[];
defaultColumnOrder?: readonly string[];
/** Hears a column being moved, with the key of every column in the order they are now shown in. */
onColumnOrderChange?: (order: readonly string[]) => void;
/**
* Problems the schema cannot state — a key used twice, a name the server refused —
* each at the place of its record in `rows` and then its field's key. A cell that has
* one is marked, and says why, as a cell the schema does not allow is.
*/
issues?: readonly Issue[];
/** Hears the active cell move: to which record, by its place in `rows`, and which field. */
onActiveCellChange?: (cell: ActiveCell | null) => void;
messages?: Partial<DataGridMessages>;
/**
* What identifies a record, for its locks to be kept under: by default its
* place in `rows`, which the grid moves locks with as it adds and removes
* records. Rows that the owner adds, removes or reorders itself need a key
* that stays with the record — its id.
*/
rowKey?: (row: Row, index: number) => string;
/**
* The locks on cells, each under `lockKey(rowKey, field)`: a frozen computed
* cell keeps its value; a held cell may not change, nor anything that would
* change it. Typing over a computed cell freezes it, and the cells' menu
* holds, freezes and unlocks.
*/
locks?: ReadonlyMap<string, Lock>;
defaultLocks?: ReadonlyMap<string, Lock>;
/** Heard before the rows' own callbacks when one action changes both: they are one change. */
onLocksChange?: (locks: ReadonlyMap<string, Lock>) => void;
};
/** The cell that typing goes to, as the data's owner knows it. */
export interface ActiveCell {
/** The place of the cell's record in `rows`; one past the last, in the row that is not yet a record. */
readonly row: number;
/** Its field's key. */
readonly key: string;
}
interface Column {
readonly key: string;
readonly label: string;
readonly schema: Schema;
readonly codec: TextCodec;
/** Whether its formula computes it: in a cycle too, where it holds nothing. */
readonly computed: boolean;
/** Whether it is shown and never changed: read-only, or computed in a cycle. */
readonly fixed: boolean;
/** Why its formula cannot be used, if it cannot. */
readonly problem: string | undefined;
}
const NO_LOCKS: ReadonlyMap<string, Lock> = new Map();
/** What a cell says of itself besides its value: locked, and computed as what. */
function statusOf(messages: LockMessages, column: Column, lock: Lock | undefined): string {
return [
lock === "held" ? messages.held : lock === "frozen" ? messages.frozen : undefined,
column.computed && column.schema.formula !== undefined
? messages.computed(column.schema.formula)
: undefined,
]
.filter(Boolean)
.join(" ");
}
/** A lock, for the eye: a padlock where it is held, a snowflake where it is frozen. */
function LockMark({ lock }: { lock: Lock }): ReactElement {
return (
<svg
aria-hidden
data-lock-mark={lock}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3 shrink-0 text-fg-muted"
>
{lock === "held" ? (
<>
<rect x="3.5" y="7" width="9" height="6.5" rx="1" />
<path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2" />
</>
) : (
<path d="M8 1.5v13M2.4 4.75l11.2 6.5M2.4 11.25l11.2-6.5M6.25 2.75 8 4.25l1.75-1.5M6.25 13.25 8 11.75l1.75 1.5" />
)}
</svg>
);
}
/** How a cell is being edited: begun by typing over it, or opened to work within its text. */
type EditMode = "typing" | "caret";
interface Edit extends Cell {
readonly mode: EditMode;
/** The record being edited, as it was: if another takes its place, the edit is over. */
readonly source: Row;
}
type Editor = HTMLInputElement | HTMLTextAreaElement;
const SCALAR = new Set(["number", "date", "text", "boolean", "choice"]);
const EMPTY_ROW: Row = Object.freeze({});
/** The cell an event landed in, read from the element's own row and column. */
function cellAt(target: EventTarget | null): Cell | null {
const element = target instanceof Element ? target.closest<HTMLElement>('[role="gridcell"]') : null;
if (element === null) return null;
return { row: Number(element.dataset.row), column: Number(element.dataset.column) };
}
/** The row whose header an event landed in. */
function headerRowAt(target: EventTarget | null): number | null {
const element = target instanceof Element ? target.closest<HTMLElement>('[role="rowheader"]') : null;
return element === null ? null : Number(element.dataset.row);
}
/** What a press landed on: a cell, a row's header, or a column's. */
type Pressed =
| { readonly kind: "cell"; readonly cell: Cell }
| { readonly kind: "row"; readonly row: number }
| { readonly kind: "column"; readonly column: number };
function pressedAt(target: EventTarget | null): Pressed | null {
const cell = cellAt(target);
if (cell !== null) return { kind: "cell", cell };
const row = headerRowAt(target);
if (row !== null) return { kind: "row", row };
const header =
target instanceof Element ? target.closest<HTMLElement>('[role="columnheader"][data-column]') : null;
return header === null ? null : { kind: "column", column: Number(header.dataset.column) };
}
/** Whether values of a column's kind may make a series, and of which kind. */
const seriesKind = (kind: string): FillKind => (kind === "number" || kind === "date" ? kind : "other");
/**
* A selection made among columns in one order, among the same columns in another: each
* corner goes with its column, and a range that took in every column still does.
*/
function withColumns(
selection: GridSelection,
from: ReadonlyArray<{ readonly key: string }>,
to: ReadonlyArray<{ readonly key: string }>,
): GridSelection {
if (from.length === to.length && from.every((column, index) => column.key === to[index]!.key)) {
return selection;
}
const places = new Map(to.map((column, index) => [column.key, index]));
const moved = (cell: Cell): Cell => ({
row: cell.row,
column: places.get(from[cell.column]?.key ?? "") ?? cell.column,
});
return selection.map((range) => {
const { left, right } = boundsOf(range);
return left === 0 && right === from.length - 1
? range
: { anchor: moved(range.anchor), focus: moved(range.focus) };
});
}
function sameBounds(a: Bounds | undefined, b: Bounds | undefined): boolean {
if (a === undefined || b === undefined) return a === b;
return a.top === b.top && a.left === b.left && a.bottom === b.bottom && a.right === b.right;
}
/** How many records a rectangle's rows hold: one at least, for a rectangle in the row that is not yet one. */
function recordsIn(bounds: Bounds, rowCount: number): number {
return Math.max(Math.min(bounds.bottom, rowCount - 1) - Math.min(bounds.top, rowCount) + 1, 1);
}
/** How many records a selection has cells of, counting each once however many ranges are in it. */
function rowsOf(selection: GridSelection, rowCount: number): number {
const spans = selection
.map(boundsOf)
.map(({ top, bottom }) => [top, Math.min(bottom, rowCount - 1)] as const)
.filter(([top, bottom]) => top <= bottom)
.toSorted((a, b) => a[0] - b[0]);
let rows = 0;
let through = -1;
for (const [top, bottom] of spans) {
if (bottom > through) rows += bottom - Math.max(top, through + 1) + 1;
through = Math.max(through, bottom);
}
return rows;
}
/** Put cells on the clipboard as text and as a table. Whether the browser allowed it. */
async function writeClipboard(text: string, html: string): Promise<boolean> {
try {
if (typeof ClipboardItem === "undefined") await navigator.clipboard.writeText(text);
else {
await navigator.clipboard.write([
new ClipboardItem({
"text/plain": new Blob([text], { type: "text/plain" }),
"text/html": new Blob([html], { type: "text/html" }),
}),
]);
}
return true;
} catch {
return false;
}
}
/** The clipboard's text, or `null` where the browser will not let a page read it. */
async function readClipboard(): Promise<string | null> {
try {
return await navigator.clipboard.readText();
} catch {
return null;
}
}
function isMultiline(schema: Schema): boolean {
return schema.kind === "text" && schema.multiline === true;
}
/** Whether an event is inside a control that answers it itself: the field being edited, or an open list. */
function isOwnControl(target: EventTarget): boolean {
return target instanceof Element && target.closest("[data-editing], [popover]") !== null;
}
interface RowProps {
readonly gridId: string;
readonly index: number;
readonly row: Row;
readonly columns: readonly Column[];
/** The width of each column, in order. */
readonly widths: readonly number[];
readonly top: number;
readonly height: number;
/** The row's selected columns; see `rowSpans`. */
readonly spans: string;
/** The column of the active cell, when it is in this row. */
readonly activeColumn: number | null;
/** How the active cell is being edited, when it is. */
readonly mode: EditMode | null;
/** Why what is typed in it cannot be read, or may not be written, in words. */
readonly problem: string | null;
/** Whether that is because locks refused it. */
readonly refused: boolean;
readonly readOnly: boolean;
/** Whether cells hold a control only once they are edited: under a finger, where a field brings up a keyboard. */
readonly lazyEditor: boolean;
readonly messages: DataGridMessages;
/** What the row's record is known by, for its locks; `null` in the row that is not yet a record. */
readonly recordKey: string | null;
readonly locks: ReadonlyMap<string, Lock>;
/** Given the active cell's field, as it comes and goes. */
readonly onEditor: (field: Editor | null) => void;
readonly onCellValue: (cell: Cell, value: unknown) => void;
readonly onEditStart: (cell: Cell) => void;
/** Whether the row begins with a header, and whether the whole row is selected. */
readonly header: boolean;
readonly wholeRow: boolean;
/** The name of the row that is not yet a record, when this is it. */
readonly newRow: string | null;
/** What is wrong with the record that the schema does not say, by its fields' keys. */
readonly marked: Readonly<Record<string, string>> | undefined;
}
/** One row of cells. Memoised: scrolling and selecting redraw only the rows they change. */
const GridRow = memo(function GridRow({
gridId,
index,
row,
columns,
widths,
top,
height,
spans,
activeColumn,
mode,
problem,
refused,
readOnly,
lazyEditor,
messages,
recordKey,
locks,
onEditor,
onCellValue,
onEditStart,
header,
wholeRow,
newRow,
marked,
}: RowProps): ReactElement {
const editor = useRef<Editor>(null);
const setEditor = useCallback(
(field: Editor | null) => {
editor.current = field;
onEditor(field);
},
[onEditor],
);
const activeSchema = activeColumn === null ? undefined : columns[activeColumn];
const shown = activeSchema === undefined ? "" : activeSchema.codec.format(row[activeSchema.key]);
// The field in the active cell is the browser's own, kept as it is typed in.
// While it is not being edited it shows the value, whole and selected, so
// that what is typed next replaces it.
useLayoutEffect(() => {
const field = editor.current;
if (mode !== null || field === null || activeColumn === null) return;
if (field.value !== shown) field.value = shown;
if (document.activeElement === field) field.select();
}, [mode, shown, activeColumn]);
return (
<div role="row" aria-rowindex={index + 2} className="absolute start-0 flex" style={{ top, height }}>
{header && (
<div
role="rowheader"
aria-colindex={1}
aria-label={newRow ?? undefined}
data-row={index}
data-selected={wholeRow ? "" : undefined}
style={{ width: HEADER }}
// Stays at the start as the columns scroll beneath it.
className="sticky start-0 z-20 flex shrink-0 items-center justify-center border-e border-b border-border-muted bg-surface-muted text-xs text-fg-muted tabular-nums hover:bg-surface-strong active:bg-surface-strong data-selected:bg-surface-strong data-selected:font-medium data-selected:text-fg-strong"
>
{newRow === null ? index + 1 : "+"}
</div>
)}
{columns.map((column, columnIndex) => {
const cell = { row: index, column: columnIndex };
const isActive = activeColumn === columnIndex;
const isEditing = isActive && mode !== null;
const value = row[column.key];
const kind = column.schema.kind;
// A row that is not yet a record holds nothing, and nothing is wrong with that.
const issue =
newRow === null ? (validate(column.schema, value)[0]?.code ?? marked?.[column.key]) : undefined;
const labelledBy = `${gridId}-header-${columnIndex}`;
const lock =
recordKey === null || locks.size === 0 ? undefined : locks.get(lockKey(recordKey, column.key));
// What it is, said with it: locked, and computed as what.
const status = statusOf(messages, column, lock);
// A column whose field is read-only is shown, selected and copied, and not changed; nor is a held cell.
const still = readOnly || column.fixed || lock === "held";
const field = isActive && !still && kind !== "boolean" && kind !== "choice";
const multiline = isMultiline(column.schema);
const choice = kind === "choice" && isActive && !still;
const shownField = field && (isEditing || !lazyEditor);
const problemId = `${gridId}-problem`;
const statusId = `${gridId}-status`;
const describedBy =
[isActive && status !== "" && statusId, isEditing && problem !== null && problemId]
.filter(Boolean)
.join(" ") || undefined;
const title = [status, issue === undefined ? undefined : messages.issue[issue]]
.filter(Boolean)
.join(" ");
// The cell itself takes focus where it holds no control to take it.
const takesFocus = isActive && (still || (lazyEditor && !isEditing && field));
const fieldProps = {
defaultValue: shown,
"aria-labelledby": labelledBy,
"aria-invalid": isEditing && problem !== null ? (true as const) : undefined,
"aria-describedby": describedBy,
"data-editing": isEditing ? "" : undefined,
autoComplete: "off",
autoCapitalize: "off",
autoCorrect: "off",
spellCheck: false,
enterKeyHint: "done" as const,
// Typing over the value begins an edit — an input method's first key among it.
onInput: () => onEditStart(cell),
className: cn(
"absolute inset-0 size-full bg-transparent px-2 text-sm text-fg-strong outline-none pointer-coarse:text-base",
// Clear of the lock's mark, which it lies over.
lock !== undefined && "ps-6",
kind === "number" && "text-end tabular-nums",
// At rest it is the cell's text and nothing more: no caret, no highlight, no press of its own.
"not-data-editing:pointer-events-none not-data-editing:cursor-default not-data-editing:caret-transparent not-data-editing:selection:bg-transparent not-data-editing:selection:text-inherit",
// Being edited, it is a field in its own right, edged as one — in the danger colour while it cannot be read.
"data-editing:z-10 data-editing:bg-surface data-editing:outline-2 data-editing:-outline-offset-2 data-editing:outline-focus-ring data-editing:outline-solid data-editing:aria-invalid:outline-danger",
),
};
return (
<div
key={column.key}
role="gridcell"
id={`${gridId}-${index}-${columnIndex}`}
aria-colindex={columnIndex + (header ? 2 : 1)}
aria-selected={inSpans(spans, columnIndex)}
aria-invalid={issue === undefined ? undefined : true}
title={title === "" ? undefined : title}
tabIndex={takesFocus ? 0 : undefined}
aria-describedby={takesFocus ? describedBy : undefined}
// A grid cell may say it is read-only; the linter's table of roles has yet to learn it.
// oxlint-disable-next-line jsx-a11y/role-supports-aria-props
aria-readonly={still && !readOnly ? true : undefined}
data-row={index}
data-column={columnIndex}
data-active={isActive ? "" : undefined}
data-kind={kind}
data-computed={column.computed ? "" : undefined}
data-lock={lock}
data-refused={isEditing && refused ? "" : undefined}
style={{ width: widths[columnIndex] }}
className={cn(
"relative flex shrink-0 items-center gap-1 border-e border-b border-border-muted px-2 text-sm text-fg-strong outline-none pointer-coarse:text-base",
// Computed and following: set apart from a value to type over.
"data-computed:not-data-lock:not-aria-selected:bg-surface-subtle",
"data-[kind=number]:justify-end data-[kind=number]:tabular-nums",
// A selection reads as the grid's accent only while the grid has focus. The
// active cell stands clear of the wash, marked by its outline alone.
"aria-selected:not-data-active:bg-surface-strong group-focus-within/grid:aria-selected:not-data-active:bg-selected-subtle",
"forced-colors:aria-selected:not-data-active:forced-color-adjust-none forced-colors:aria-selected:not-data-active:bg-[Highlight]! forced-colors:aria-selected:not-data-active:text-[HighlightText]",
// The field being edited draws the edge itself.
"has-data-editing:outline-none!",
"data-active:outline-2 data-active:-outline-offset-2 data-active:outline-border data-active:outline-solid group-focus-within/grid:data-active:outline-focus-ring",
"aria-invalid:shadow-[inset_0_-2px_0_var(--ui-danger)]",
)}
>
{lock !== undefined && <LockMark lock={lock} />}
{isActive && status !== "" && (
<span id={statusId} hidden>
{status}
</span>
)}
{kind === "boolean" ? (
<Checkbox
tabIndex={isActive ? 0 : -1}
aria-labelledby={labelledBy}
aria-describedby={describedBy}
aria-readonly={still || undefined}
checked={typeof value === "boolean" ? value : null}
onCheckedChange={(next) => {
if (!still) onCellValue(cell, next);
}}
/>
) : (
// Always there, under the control when there is one: a press that
// makes the cell active lands on it, and it must outlast the press.
<span className={cn("truncate", (choice || shownField) && "invisible")}>
{column.codec.format(value)}
</span>
)}
{choice && (
<Select
aria-labelledby={labelledBy}
aria-describedby={describedBy}
value={typeof value === "string" ? value : null}
onValueChange={(next) => onCellValue(cell, next)}
className="absolute inset-0 h-full rounded-none border-0 bg-transparent pointer-coarse:h-full pointer-coarse:text-base"
>
{column.schema.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</Select>
)}
{shownField &&
(multiline ? (
<textarea
ref={setEditor}
{...fieldProps}
rows={1}
className={cn(
fieldProps.className,
"resize-none py-1.5 leading-5 not-data-editing:overflow-hidden not-data-editing:whitespace-nowrap",
// Opened, it is as tall as its text, over the rows below.
"data-editing:inset-auto data-editing:start-0 data-editing:top-0 data-editing:z-20 data-editing:h-auto data-editing:max-h-48 data-editing:min-h-full data-editing:bg-surface-raised data-editing:shadow-lg data-editing:[field-sizing:content]",
)}
/>
) : (
<input ref={setEditor} type="text" {...fieldProps} />
))}
{isEditing && problem !== null && (
<div
id={problemId}
className="absolute start-0 top-full z-20 mt-0.5 rounded-md border border-danger bg-surface-raised px-2 py-1 text-xs whitespace-nowrap text-danger shadow-lg"
>
{problem}
</div>
)}
</div>
);
})}
</div>
);
});
/** One action, as the changes it made, in the order it made them. */
interface HistoryEntry {
readonly edits: ReadonlyArray<RecordChange<Row>>;
/**
* The order the rows were shown in before the action and after it, and the
* sort those orders were made under: undone under the same sort, an action
* puts every row back where it was shown.
*/
readonly sort: GridSort | null;
readonly before: ViewOrder | null;
readonly after: ViewOrder | null;
/** The locks before the action and after it, when it changed them. */
readonly locks: {
readonly before: ReadonlyMap<string, Lock>;
readonly after: ReadonlyMap<string, Lock>;
} | null;
}
/** What a change of cells came to: how many of the cells asked for were written, and what locks refused. */
interface Applied {
readonly done: number;
readonly refused: readonly Refusal[];
}
/**
* A grid of records: a row for each, a column for each field of their schema,
* every cell shown and edited as its kind calls for. It draws only the rows in
* view, so a million rows scroll as a hundred do; it moves, selects, copies,
* pastes and undoes as a spreadsheet does.
*
* Focus is on the active cell's own control — a field, a checkbox, a list
* button — so that what is typed is typed into it directly: an input method
* composes its first key, and the clipboard is the browser's own.
*/
export function DataGrid({
schema,
rows,
onRowsChange,
onCellsChange,
onRowsInsert,
onRowsRemove,
rowHeaders = true,
locale: localeProp,
columnWidth = 160,
columnWidths: columnWidthsProp,
defaultColumnWidths = NO_WIDTHS,
onColumnWidthsChange,
columnOrder: columnOrderProp,
defaultColumnOrder = NO_ORDER,
onColumnOrderChange,
issues,
onActiveCellChange,
sort: sortProp,
defaultSort = null,
onSortChange,
messages: messagesProp,
rowKey,
locks: locksProp,
defaultLocks = NO_LOCKS,
onLocksChange,
className,
ref,
...props
}: DataGridProps): ReactElement {
const gridId = useId();
const locale = useLocale(localeProp);
const coarse = useCoarsePointer();
const height = coarse ? 44 : 32;
const canInsert = onRowsChange !== undefined || onRowsInsert !== undefined;
const canRemove = onRowsChange !== undefined || onRowsRemove !== undefined;
const readOnly = onRowsChange === undefined && onCellsChange === undefined && !canInsert;
const headerWidth = rowHeaders ? HEADER : 0;
const messages = useMemo(
() => ({
...MESSAGES,
...messagesProp,
unreadable: { ...MESSAGES.unreadable, ...messagesProp?.unreadable },
issue: { ...MESSAGES.issue, ...messagesProp?.issue },
}),
[messagesProp],
);
const [locks, setLocks] = useControllableState(locksProp, defaultLocks, onLocksChange);
// Whether the owner works with locks: then every cell's menu offers them, else only a computed one's.
const offersLocks = locksProp !== undefined || defaultLocks !== NO_LOCKS || onLocksChange !== undefined;
// The locks as the last action left them, for one that follows before the next render.
const locksNow = useRef(locks);
useLayoutEffect(() => {
locksNow.current = locks;
});
/** What the record at a place in the data is known by, for its locks. */
const keyOf = useCallback(
(record: number, row: Row | undefined = rows[record]) =>
rowKey === undefined || row === undefined ? String(record) : rowKey(row, record),
[rowKey, rows],
);
const [columnOrder, setColumnOrder] = useControllableState(
columnOrderProp,
defaultColumnOrder,
onColumnOrderChange,
);
const fields = useMemo<Column[]>(() => {
const { plan, problems } = derivationsOf(schema);
return (
schema.fields
// A field given by reference is the field it refers to.
.map((field) => ({ key: field.key, schema: resolve(field.schema) }))
.filter((field) => SCALAR.has(field.schema.kind))
.map((field) => ({
key: field.key,
label: field.schema.label ?? field.key,
schema: field.schema,
codec: textCodec(field.schema, locale),
computed: plan.computes(field.key),
fixed: field.schema.readOnly === true || plan.cyclic.has(field.key),
problem: problems.find((problem) => problem.key === field.key)?.message,
}))
);
}, [schema, locale]);
const columns = useMemo(() => arrange(fields, columnOrder, (column) => column.key), [fields, columnOrder]);
const rowCount = rows.length;
const columnCount = columns.length;
const [ownWidths, setOwnWidths] = useControllableState(
columnWidthsProp,
defaultColumnWidths,
onColumnWidthsChange,
);
// Each column's width, and where each starts: one past the last is where they all end.
const widths = useMemo(
() => columns.map((column) => clampWidth(ownWidths[column.key] ?? columnWidth)),
[columns, ownWidths, columnWidth],
);
const [sort, setSort] = useControllableState<GridSort | null>(sortProp, defaultSort, onSortChange);
const orderFor = useCallback(
(records: readonly Row[], by: GridSort | null): ViewOrder | null => {
const column = by === null ? undefined : columns.find((candidate) => candidate.key === by.key);
if (by === null || column === undefined) return null;
return sortedOrder(
records,
column.key,
compareFor(column.schema, locale),
by.direction === "ascending" ? 1 : -1,
);
},
[columns, locale],
);
// Where each row's record is in the data, while the rows are sorted; kept through
// the grid's own edits, so that a row does not jump away as it is typed in.
const [kept, setOrder] = useState<ViewOrder | null>(() => orderFor(rows, sort));
// An order made for other rows is no order for these.
const order = kept !== null && kept.length === rowCount ? kept : null;
const orderNow = useRef(order);
useLayoutEffect(() => {
orderNow.current = order;
});
// The rows as they stand, and the sort, that the order answers to.
const answered = useRef({ rows, sort, orderFor });
/** The place in the data of the record a row shows. A row past the last record is its own place. */
const recordAt = useCallback(
(row: number): number => (order !== null && row < order.length ? order[row]! : row),
[order],
);
const starts = useMemo(() => {
const at = new Float64Array(widths.length + 1);
for (let index = 0; index < widths.length; index += 1) at[index + 1] = at[index]! + widths[index]!;
return at;
}, [widths]);
// The rows shown: the records, and after them one that becomes a record when it is typed in.
const viewRows = rowCount + (canInsert ? 1 : 0);
const extent = viewRows > 0 && columnCount > 0;
// How far a whole column reaches, and where the edges are: the records, not the row that waits to be one.
const reach = rowCount > 0 ? rowCount : viewRows;
const scroller = useRef<HTMLDivElement>(null);
const editor = useRef<Editor>(null);
const onEditor = useCallback((field: Editor | null) => {
editor.current = field;
}, []);
const [scrollTop, setScrollTop] = useState(0);
const [pin, setPin] = useState<{ readonly at: number; readonly offset: number } | null>(null);
const pinned = useRef(pin);
const [viewport, setViewport] = useState(0);
// How far the columns are scrolled, and how wide the view is: what a handle over the headers is hidden by.
const [scrollStart, setScrollStart] = useState(0);
const [viewportWidth, setViewportWidth] = useState(0);
const [chosen, setSelection] = useState<GridSelection>([]);
// The columns as the selection was made among them: when they are put in another
// order, each corner of the selection goes with its column.
const [placed, setPlaced] = useState(columns);
if (placed !== columns) {
setPlaced(columns);
setSelection(withColumns(chosen, placed, columns));
}
const [edit, setEdit] = useState<Edit | null>(null);
const [problem, setProblem] = useState<{ readonly text: string; readonly refused: boolean } | null>(null);
const { announce, regionProps: announcementProps } = useAnnouncer();
// The cells a fill being dragged out would fill, and whether the menu is open.
const [fillPreview, setFillPreview] = useState<FillTarget | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
// Whether a finger has the selection by a handle: what is held is not taken from under it.
const [holding, setHolding] = useState(false);
// The selection, held inside a grid whose rows may have been taken away; and
// where none has been made, the first cell, as soon as there is one.
const selection = useMemo(
() =>
chosen.length === 0 && extent
? selectCell({ row: 0, column: 0 })
: clampSelection(chosen, viewRows, columnCount),
[chosen, extent, viewRows, columnCount],
);
const active = activeCell(selection);
// Told to the owner as the owner knows it: the record's place in the data, and the field.
const activeRecord = active === null ? null : recordAt(active.row);
const activeKey = active === null ? undefined : columns[active.column]?.key;
// The cell last told of, as one word; none, until the grid has been drawn once.
const told = useRef("");
useEffect(() => {
const cell =
activeRecord === null || activeKey === undefined ? null : { row: activeRecord, key: activeKey };
const now = cell === null ? "none" : `${cell.row} ${cell.key}`;
const was = told.current;
told.current = now;
// Where the grid begins is not a move.
if (was !== "" && was !== now) onActiveCellChange?.(cell);
}, [activeRecord, activeKey, onActiveCellChange]);
// What is wrong that the schema does not say, by record and then by field.
const marks = useMemo(() => {
const byRecord = new Map<number, Record<string, string>>();
for (const { path, code } of issues ?? []) {
const [record, key] = path;
if (typeof record !== "number" || typeof key !== "string") continue;
const held = byRecord.get(record) ?? {};
// The first said of a cell is the one it shows.
held[key] ??= code;
byRecord.set(record, held);
}
return byRecord;
}, [issues]);
// An edit lasts only while its cell is there to edit, holding the record it began with.
const editing =
edit !== null &&
!readOnly &&
active !== null &&
active.row === edit.row &&
active.column === edit.column &&
(rows[recordAt(edit.row)] ?? EMPTY_ROW) === edit.source
? edit
: null;
useLayoutEffect(() => {
const element = scroller.current;
if (element === null) return undefined;
const measure = () => {
setViewport(element.clientHeight);
setViewportWidth(element.clientWidth);
// A grid hidden and shown again may have been scrolled back without a word.
setScrollTop(element.scrollTop);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, []);
// The header covers the first row's worth of the view.
const bodyViewport = Math.max(viewport - height, 0);
const size = viewRows * height;
// Where the view is in the list, which is the scroll position until the list is
// too long for one. Then a scroll position stands for many places, and the one
// a move asked for is remembered while the scroll position stays where it put it.
const offset =
pin !== null && pin.at === scrollTop ? pin.offset : listOffset(scrollTop, size, bodyViewport);
const view = fixedWindow(viewRows, height, offset, bodyViewport, 8);
const pageSize = Math.max(Math.floor(bodyViewport / height) - 1, 1);
const reveal = useCallback(
(cell: Cell) => {
const element = scroller.current;
if (element === null) return;
const body = Math.max(element.clientHeight - height, 0);
const total = viewRows * height;
const held = pinned.current;
const at =
held !== null && held.at === element.scrollTop
? held.offset
: listOffset(element.scrollTop, total, body);
const next = scrollToReveal(cell.row * height, height, at, body);
if (next !== at) {
element.scrollTop = scrollOffsetFor(next, total, body);
// Read back: the browser keeps a scroll position only so finely.
pinned.current = { at: element.scrollTop, offset: next };
setPin(pinned.current);
setScrollTop(element.scrollTop);
}
// Right to left, a scroll position counts down from zero.
const sign = getComputedStyle(element).direction === "rtl" ? -1 : 1;
element.scrollLeft =
sign *
scrollToReveal(
starts[cell.column] ?? 0,
widths[cell.column] ?? 0,
sign * element.scrollLeft,
// The row headers stay over the start of the view: a cell beneath them is not in it.
element.clientWidth - headerWidth,
);
},
[height, starts, widths, viewRows, headerWidth],
);
// Whether focus is to follow the active cell: it does when it was in the grid, or a press put it there.
const refocus = useRef(false);
const select = useCallback(
(next: GridSelection) => {
const element = scroller.current;
refocus.current ||= element !== null && element.contains(document.activeElement);
setSelection(next);
const far = focusCell(next);
if (far !== null) reveal(far);
},
[reveal],
);
/** The control in the active cell, or the cell where it holds none. */
const focusTarget = useCallback((): HTMLElement | null => {
const cell = scroller.current?.querySelector<HTMLElement>("[data-active]") ?? null;
return (
cell?.querySelector<HTMLElement>(
':scope > :is(input, textarea, button), :scope > span > input[type="checkbox"]',
) ?? cell
);
}, []);
const idle = editing === null;
const activeRow = active?.row;
const activeColumn = active?.column;
useLayoutEffect(() => {
if (!refocus.current) return;
refocus.current = false;
const target = focusTarget();
target?.focus({ preventScroll: true });
if (idle && target === editor.current) editor.current?.select();
// The active cell is not read: its moving is the reason to run.
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [activeRow, activeColumn, idle, focusTarget]);
/** Give the grid focus by a press: on the active cell's control, now if the press does not move it. */
function takeFocus(to: Cell): void {
if (active !== null && active.row === to.row && active.column === to.column) {
const target = focusTarget();
if (target !== document.activeElement) {
target?.focus({ preventScroll: true });
if (idle && target === editor.current) editor.current?.select();
}
} else refocus.current = true;
}
const [history] = useState(() => createHistory<HistoryEntry>());
// Whether the next change of `rows` is the grid's own, come back from its owner.
const expecting = useRef(false);
useEffect(() => {
const was = answered.current;
const replaced = was.rows !== rows && !expecting.current;
answered.current = { rows, sort, orderFor };
if (was.rows !== rows) expecting.current = false;
// Rows replaced from outside are other rows: what was done to the old ones cannot be undone in these.
if (replaced) history.clear();
// And they are sorted afresh, as they are when the sort is changed — but not when the
// grid's own edit comes back, which must leave every row where it is.
if (replaced || was.sort !== sort || was.orderFor !== orderFor) {
// oxlint-disable-next-line react/set-state-in-effect -- answering rows and a sort owned outside
setOrder(orderFor(rows, sort));
}
}, [rows, sort, orderFor, history]);
/** Make changes, telling the owner and — unless they are an undo — remembering them as one action. */
const perform = useCallback(
(
asked: ReadonlyArray<RecordChange<Row>>,
shownAs?: ViewOrder | null,
locked?: ReadonlyMap<string, Lock>,
// Locks on records the action puts in, under their places once they are in.
fresh: ReadonlyArray<readonly [string, Lock]> = [],
): boolean => {
const edits: Array<RecordChange<Row>> = [];
for (const change of asked) {
if (change.kind === "cells") {
const cells = change.cells.filter((cell) => !Object.is(cell.previous, cell.value));
if (cells.length > 0) edits.push({ kind: "cells", cells });
} else if (change.kind === "insert" ? change.rows.length > 0 : change.runs.length > 0) {
edits.push(change);
}
}
const was = locksNow.current;
let lockNext = locked ?? was;
// Locks go with their records as records are put in and taken out — but an undo puts back the locks it kept.
if (shownAs === undefined) {
for (const change of edits) {
if (rowKey === undefined) lockNext = locksAfter(lockNext, change);
else if (change.kind === "remove" && lockNext.size > 0) {
const gone = new Set(
change.runs.flatMap((run) => run.rows.map((row, at) => rowKey(row, run.at + at))),
);
const remaining = new Map(
[...lockNext].filter(([key]) => !gone.has(key.slice(0, key.lastIndexOf("\u001f")))),
);
if (remaining.size !== lockNext.size) lockNext = remaining;
}
}
}
if (fresh.length > 0) lockNext = new Map([...lockNext, ...fresh]);
if (edits.length === 0 && lockNext === was) return false;
// Heard first: a lock and the change it came with are one.
if (lockNext !== was) {
locksNow.current = lockNext;
setLocks(lockNext);
}
if (edits.length > 0) expecting.current = true;
let next: readonly Row[] = rows;
for (const change of edits) {
if (change.kind === "cells") onCellsChange?.(change.cells);
else if (change.kind === "insert") onRowsInsert?.(change.at, change.rows);
else onRowsRemove?.(change.runs);
if (onRowsChange !== undefined) next = applyChange(next, change);
}
if (onRowsChange !== undefined && edits.length > 0) onRowsChange(next as Row[]);
// The order follows the edit, each row that is still there staying where it was.
// — or, for an action undone or done again, the order it was shown in then.
const before = orderNow.current;
let after = before;
if (shownAs !== undefined) after = shownAs;
else if (after !== null) for (const change of edits) after = orderAfter(after, change);
// Remembered, unless this is itself an undo or a redo.
if (shownAs === undefined) {
history.push({
edits,
sort,
before,
after,
locks: lockNext === was ? null : { before: was, after: lockNext },
});
}
if (after !== before) {
orderNow.current = after;
setOrder(after);
}
return true;
},
[rows, sort, onRowsChange, onCellsChange, onRowsInsert, onRowsRemove, history, rowKey, setLocks],
);
const fixed = useMemo(
() => new Set(columns.filter((column) => column.fixed).map((column) => column.key)),
[columns],
);
/** The lock on a cell of the record at a place in the data. */
const lockAt = useCallback(
(record: number, key: string): Lock | undefined =>
locks.size === 0 || record >= rowCount ? undefined : locks.get(lockKey(keyOf(record), key)),
[locks, rowCount, keyOf],
);
/** An empty record, with what it computes from nothing. */
const blankRow = useCallback(
(): Row => deriveRecord(derivationsOf(schema).plan, blank(schema) as Row),
[schema],
);
/**
* Change cells, as one action, with every value they compute. Those past the
* last record make new records to hold them. A computed cell written over is
* frozen at what was written; what locks refuse is left, and returned.
*/
const apply = useCallback(
(changes: readonly CellChange[]): Applied => {
const existing: CellChange[] = [];
const created = new Map<number, Record<string, unknown>>();
for (const change of changes) {
// Clearing, pasting and filling pass over a column that is only shown.
if (fixed.has(change.key)) continue;
if (change.row < rowCount) existing.push(change);
// Nothing is not worth a record: a cell cleared where there is none makes none.
else if (canInsert && change.value !== null && change.value !== undefined) {
created.set(change.row, { ...created.get(change.row), [change.key]: change.value });
}
}
const { plan } = derivationsOf(schema);
const written = editRecords(plan, rows, existing, lockAt);
const edits: Array<RecordChange<Row>> = written.change === null ? [] : [written.change];
let locked = locksNow.current;
const freeze = (key: string) => {
if (locked === locksNow.current) locked = new Map(locked);
(locked as Map<string, Lock>).set(key, "frozen");
};
for (const { row, key } of written.frozen) freeze(lockKey(keyOf(row), key));
const frozenNew: Array<readonly [string, Lock]> = [];
if (created.size > 0) {
const last = Math.max(...created.keys());
const blanks: Row[] = [];
const cells: CellChange[] = [];
for (let row = rowCount; row <= last; row += 1) {
const empty = blankRow();
blanks.push(empty);
for (const [key, value] of Object.entries(created.get(row) ?? {})) {
cells.push({ row: row - rowCount, key, value, previous: empty[key] ?? null });
}
}
// A new record is written as any other, from empty: what is typed into a computed cell freezes it.
const fresh = editRecords(plan, blanks, cells, () => undefined);
const added = fresh.change === null ? blanks : applyChange(blanks, fresh.change);
edits.push({ kind: "insert", at: rowCount, rows: added });
for (const { row, key } of fresh.frozen) {
frozenNew.push([lockKey(keyOf(rowCount + row, added[row]), key), "frozen"]);
}
}
perform(edits, undefined, locked, frozenNew);
return { done: changes.length - written.refused.length, refused: written.refused };
},
[rowCount, canInsert, schema, perform, fixed, rows, lockAt, keyOf, blankRow],
);
/** What locks refused, said once: how many cells, and the columns whose locks refused them. */
const refusalOf = useCallback(
(refused: readonly Refusal[]): string => {
if (refused.length === 0) return "";
const by = [...new Set(refused.map((refusal) => refusal.by))].map(
(key) => columns.find((column) => column.key === key)?.label ?? key,
);
return messages.refusedBy(refused.length, by);
},
[columns, messages],
);
const changeOf = useCallback(
(cell: Cell, value: unknown): CellChange => {
const key = columns[cell.column]!.key;
// A cell with nothing in it holds nothing, whether or not its record says so.
const row = recordAt(cell.row);
return { row, key, value, previous: rows[row]?.[key] ?? null };
},
[columns, rows, recordAt],
);
// A press with Shift or the command key selects: the checkbox it lands on is not to turn.
const selecting = useRef(false);
// What the last press landed on, for a menu asked for there; nothing, once a key has been pressed.
const pressed = useRef<Pressed | null>(null);
// The block a fill is being dragged out of, and the cells it would fill as the drag stands.
const fillSource = useRef<Bounds | null>(null);
const fillNow = useRef<FillTarget | null>(null);
const onCellValue = useCallback(
(cell: Cell, value: unknown) => {
if (!selecting.current) apply([changeOf(cell, value)]);
},
[apply, changeOf],
);
const endEdit = useCallback(() => {
const element = scroller.current;
refocus.current = element !== null && element.contains(document.activeElement);
flushSync(() => {
setEdit(null);
setProblem(null);
});
}, []);
/**
* Read what is typed into the cell being edited, and end the edit — unless it
* cannot be read, when the edit stays open and says why. With `fill`, every
* selected cell takes what was typed, each read as its own column reads it.
*/
const commit = useCallback(
(fill = false): boolean => {
if (editing === null) return true;
const field = editor.current;
const column = columns[editing.column];
if (field === null || column === undefined) return true;
const result = column.codec.parse(field.value);
if (!result.ok && result.reason !== "empty") {
const words = messages.unreadable[result.reason] ?? messages.unreadable.invalid ?? "";
setProblem({ text: words, refused: false });
announce(words);
return false;
}
const changes = [changeOf(editing, result.ok ? result.value : null)];
if (fill) {
for (const range of selection) {
const { top, left, bottom, right } = boundsOf(range);
for (let row = top; row <= bottom; row += 1) {
for (let index = left; index <= right; index += 1) {
if (row === editing.row && index === editing.column) continue;
const read = columns[index]!.codec.parse(field.value);
if (read.ok) changes.push(changeOf({ row, column: index }, read.value));
else if (read.reason === "empty") changes.push(changeOf({ row, column: index }, null));
}
}
}
}
const { refused } = apply(changes);
if (!fill && refused.length > 0) {
// Kept as typed, marked, with why: Escape puts it aside, and Enter asks for it again.
const by = refused[0]!.by;
const words = messages.refused(columns.find((candidate) => candidate.key === by)?.label ?? by);
setProblem({ text: words, refused: true });
announce(messages.problem(column.label, words));
return false;
}
if (refused.length > 0) announce(refusalOf(refused));
endEdit();
return true;
},
[editing, columns, messages, announce, changeOf, selection, apply, endEdit, refusalOf],
);
const beginEdit = useCallback(
(cell: Cell, mode: EditMode) => {
const column = columns[cell.column];
const source = rows[recordAt(cell.row)] ?? (cell.row < viewRows ? EMPTY_ROW : undefined);
if (readOnly || column === undefined || source === undefined || column.fixed) return;
if (lockAt(recordAt(cell.row), column.key) === "held") {
announce(messages.problem(column.label, messages.held));
return;
}
const control = focusTarget();
if (column.schema.kind === "boolean" || column.schema.kind === "choice") {
// Neither has text to edit: a checkbox turns, and a choice shows its list.
control?.click();
return;
}
// Synchronously, so that a key which began the edit is typed into the field.
flushSync(() => setEdit({ ...cell, mode, source }));
const field = editor.current;
if (field === null) return;
field.focus({ preventScroll: true });
if (mode === "caret") field.setSelectionRange(field.value.length, field.value.length);
else field.select();
},
[columns, rows, recordAt, viewRows, readOnly, focusTarget, lockAt, announce, messages],
);
const onEditStart = useCallback(
(cell: Cell) => {
const source = rows[recordAt(cell.row)] ?? (cell.row < viewRows ? EMPTY_ROW : undefined);
if (source === undefined) return;
setEdit((current) =>
// An edit already under way in this cell goes on as it is; one left over from a record that has gone does not.
current !== null &&
current.row === cell.row &&
current.column === cell.column &&
current.source === source
? current
: { ...cell, mode: "typing", source },
);
},
[rows, recordAt, viewRows],
);
const tabOrigin = useRef<number | null>(null);
/** Move from a cell that has just been committed: onward as a spreadsheet moves. */
function advance(from: Cell, by: "down" | "up" | "right" | "left"): void {
let next: Cell;
if (by === "down" || by === "up") {
// After a run of Tabs across a row, Enter returns to the column the run began in.
const column = tabOrigin.current ?? from.column;
tabOrigin.current = null;
next = { row: Math.min(Math.max(from.row + (by === "down" ? 1 : -1), 0), viewRows - 1), column };
} else {
tabOrigin.current ??= from.column;
const index = from.row * columnCount + from.column + (by === "right" ? 1 : -1);
// Past the end of a row, on to the start of the next.
const held = Math.min(Math.max(index, 0), viewRows * columnCount - 1);
next = { row: Math.floor(held / columnCount), column: held % columnCount };
}
select(selectCell(next));
}
function clear(bounds: readonly Bounds[]): void {
const changes: CellChange[] = [];
const seen = new Set<string>();
for (const { top, left, bottom, right } of bounds) {
for (let row = top; row <= bottom; row += 1) {
for (let column = left; column <= right; column += 1) {
const id = `${row}:${column}`;
if (seen.has(id)) continue;
seen.add(id);
// The row that is not yet a record has nothing to clear.
if (row < rowCount) changes.push(changeOf({ row, column }, null));
}
}
}
const { refused } = apply(changes);
if (refused.length > 0) announce(refusalOf(refused));
}
/** The whole of rows `from` to `to`, with the active cell kept in its column. */
function wholeRows(from: number, to: number, at: Cell): GridSelection {
return [...selectRows(Math.min(from, to), Math.max(from, to), columnCount), { anchor: at, focus: at }];
}
function restore(entry: HistoryEntry | undefined, forward: boolean): void {
if (entry === undefined) return;
const edits = forward ? entry.edits : entry.edits.toReversed().flatMap(invertChange);
// Under the sort it was made under, every row goes back where it was shown;
// under another, the rows that return are shown as rows that are added.
let shownAs = forward ? entry.after : entry.before;
if (entry.sort !== sort) {
shownAs = orderNow.current;
if (shownAs !== null) for (const change of edits) shownAs = orderAfter(shownAs, change);
}
perform(
edits,
shownAs,
entry.locks === null ? undefined : forward ? entry.locks.after : entry.locks.before,
);
// Show what changed: select the rectangle around the cells, or the rows put back
// — where they are shown now, which while sorted is not where they are kept.
const places = orderNow.current === null ? null : placesIn(orderNow.current);
const shownAt = (record: number) =>
places !== null && record < places.length ? places[record]! : record;
let top = Infinity;
let left = Infinity;
let bottom = -1;
let right = -1;
for (const done of edits) {
if (done.kind === "cells") {
for (const change of done.cells) {
const column = columns.findIndex((candidate) => candidate.key === change.key);
if (column === -1) continue;
top = Math.min(top, shownAt(change.row));
bottom = Math.max(bottom, shownAt(change.row));
left = Math.min(left, column);
right = Math.max(right, column);
}
} else if (done.kind === "insert") {
// Records put in together are shown together.
top = Math.min(top, shownAt(done.at));
bottom = Math.max(bottom, shownAt(done.at) + done.rows.length - 1);
left = 0;
right = columnCount - 1;
}
}
if (bottom >= 0) {
select(extendTo(selectCell({ row: top, column: left }), { row: bottom, column: right }));
}
announce(forward ? messages.redone : messages.undone);
}
/** Remove the rows that are selected whole — or, when asked by name, every row the selection is in. Returns whether there were any. */
function removeRows(touched = false): boolean {
if (!canRemove || active === null) return false;
const indexes: number[] = [];
let first = Infinity;
for (const range of selection) {
const { top, left, bottom, right } = boundsOf(range);
if (!touched && (left !== 0 || right !== columnCount - 1)) continue;
for (let row = top; row <= bottom && row < rowCount; row += 1) {
indexes.push(recordAt(row));
first = Math.min(first, row);
}
}
const runs = runsOf(rows, indexes);
if (runs.length === 0) return false;
perform([{ kind: "remove", runs }]);
const removed = runs.reduce((count, run) => count + run.rows.length, 0);
// On to the row that has come up into the first one's place.
select(selectCell({ row: Math.min(first, Math.max(viewRows - removed - 1, 0)), column: active.column }));
announce(messages.removed(removed));
return true;
}
/** Put empty records in above the selection: as many as the rows it is in. */
function insertRows(): void {
const bounds = clipboardBounds(selection);
if (!canInsert || bounds === null || active === null) return;
const top = Math.min(bounds.top, rowCount);
const many = recordsIn(bounds, rowCount);
perform([
{
kind: "insert",
at: top < rowCount ? recordAt(top) : rowCount,
rows: Array.from({ length: many }, blankRow),
},
]);
select(wholeRows(top, top + many - 1, { row: top, column: active.column }));
announce(messages.inserted(many));
}
const valueAt = (row: number, column: number) => rows[recordAt(row)]?.[columns[column]!.key] ?? null;
/**
* Fill cells from the block beside them, as one change. Down the rows, each
* column continues or repeats its own values; along the columns, each row its
* own. Read-only columns are left, and how many cells that was is said.
*/
function fillCells(source: Bounds, target: FillTarget, reselect: boolean): void {
const changes: CellChange[] = [];
let left = 0;
let unread = 0;
const to = target.bounds;
if (target.axis === "rows") {
for (let column = to.left; column <= to.right; column += 1) {
const line: unknown[] = [];
for (let row = source.top; row <= source.bottom; row += 1) line.push(valueAt(row, column));
const next = continuation(line, seriesKind(columns[column]!.schema.kind));
for (let row = to.top; row <= to.bottom; row += 1) {
if (fixed.has(columns[column]!.key)) left += 1;
else {
const value = next(target.forward ? row - source.bottom : row - source.top);
if (value !== undefined) changes.push(changeOf({ row, column }, value));
}
}
}
} else {
const from: number[] = [];
for (let column = source.left; column <= source.right; column += 1) from.push(column);
const kinds = new Set(from.map((column) => columns[column]!.schema.kind));
const origin = continuation(from);
for (let row = to.top; row <= to.bottom; row += 1) {
const line = from.map((column) => valueAt(row, column));
for (let column = to.left; column <= to.right; column += 1) {
const into = columns[column]!;
const kind = into.schema.kind;
const step = target.forward ? column - source.right : column - source.left;
if (fixed.has(into.key)) left += 1;
else if (seriesKind(kind) !== "other" && kinds.size === 1 && kinds.has(kind)) {
// A series runs on only through columns of its own kind.
const value = continuation(line, seriesKind(kind))(step);
if (value !== undefined) changes.push(changeOf({ row, column }, value));
} else {
const out = columns[origin(step) as number]!;
const value = valueAt(row, origin(step) as number);
// Between columns of different kinds a value goes as its text, read as the column reads it.
const read =
out.schema.kind === kind && kind !== "choice"
? ({ ok: true, value } as const)
: into.codec.read(out.codec.format(value));
if (read.ok) changes.push(changeOf({ row, column }, read.value));
else if (read.reason === "empty") changes.push(changeOf({ row, column }, null));
else unread += 1;
}
}
}
}
const { done, refused } = apply(changes);
if (reselect) {
// The block and what it filled, as one, measured from the block's own corner.
select(
target.forward
? extendTo(selectCell({ row: source.top, column: source.left }), {
row: to.bottom,
column: to.right,
})
: extendTo(selectCell({ row: source.bottom, column: source.right }), {
row: to.top,
column: to.left,
}),
);
}
announce([messages.filled(done, left, unread), refusalOf(refused)].filter(Boolean).join(" "));
}
/** Fill the selection down from its first row, or across from its first column. */
function fillFrom(axis: FillTarget["axis"]): void {
const bounds = clipboardBounds(selection);
const lines = bounds === null || readOnly ? null : fillFromFirst(bounds, axis);
if (lines !== null) fillCells(lines.source, lines.target, false);
}
/** Where the selection's series is, and what it fills: the lines at its start that hold values, through the rest. */
function seriesOf(bounds: Bounds | null): ReturnType<typeof fillSeries> {
if (bounds === null || readOnly) return null;
const down = bounds.bottom > bounds.top;
return fillSeries(bounds, (line) => {
for (
let across = down ? bounds.left : bounds.top;
across <= (down ? bounds.right : bounds.bottom);
across += 1
) {
if (hasValue(down ? valueAt(bounds.top + line, across) : valueAt(across, bounds.left + line)))
return true;
}
return false;
});
}
function moveBy(move: GridMove, extend: boolean): void {
if (active === null) return;
// A step may go on into the row that waits to become a record; a move to an edge stops at the last one.
const stepping = move.kind === "step" || move.kind === "page";
const bounds = { rowCount: stepping ? viewRows : reach, columnCount, pageSize };
tabOrigin.current = null;
// Whole rows, or whole columns, with the active cell among them: Shift with
// an arrow along them takes in the next whole one, as it did the first.
const [span, own] = selection;
if (
extend &&
selection.length === 2 &&
span !== undefined &&
own !== undefined &&
(move.kind === "step" || move.kind === "edge" || move.kind === "page")
) {
const { top, left, bottom, right } = boundsOf(span);
const vertical = move.kind === "page" || move.rows !== 0;
if (vertical && left === 0 && right === columnCount - 1) {
const far = top === active.row ? bottom : top;
const to = applyGridMove({ row: far, column: active.column }, move, {
...bounds,
rowCount: reach,
}).row;
setSelection(wholeRows(active.row, to, active));
reveal({ row: to, column: active.column });
return;
}
if (!vertical && top === 0 && bottom === reach - 1) {
const far = left === active.column ? right : left;
const to = applyGridMove({ row: active.row, column: far }, move, bounds).column;
setSelection([
{
anchor: { row: 0, column: Math.min(active.column, to) },
focus: { row: reach - 1, column: Math.max(active.column, to) },
},
{ anchor: active, focus: active },
]);
reveal({ row: active.row, column: to });
return;
}
}
if (extend) select(extendTo(selection, applyGridMove(focusCell(selection) ?? active, move, bounds)));
else select(selectCell(applyGridMove(active, move, bounds)));
}
// Keys are heard on their way down to the control that has focus, so that the
// grid's own — arrows over a list button, Enter in a field — are answered here
// and the rest left to the control.
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>): void {
const target = event.target as HTMLElement;
selecting.current = false;
// A menu asked for after a key is asked for by the keyboard, not where the last press was.
pressed.current = null;
// Escape takes back a fill that is being dragged out.
if (fillSource.current !== null && event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
cancelFill();
stopDrag();
return;
}
// An open list of choices has the keyboard to itself, and so does a column's edge.
if (target.closest('[popover], [role="separator"]') !== null) return;
if (active === null) {
// With no cells, the grid itself is the tab stop, and the arrows go on from it to the headers.
const header = target.closest<HTMLElement>('[role="columnheader"][data-column]');
if (header !== null) handleHeaderKey(event, Number(header.dataset.column));
else if (target === event.currentTarget && TO_HEADERS.has(event.key) && columns[0] !== undefined) {
event.preventDefault();
headerControl(columns[0].key)?.focus();
}
return;
}
// Escape takes back a header that is being dragged.
if (columnDrag.current !== null && event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
endColumnDrag(false);
return;
}
const header = target.closest<HTMLElement>('[role="columnheader"][data-column]');
if (header !== null) {
if (editing === null) handleHeaderKey(event, Number(header.dataset.column));
return;
}
const composing = event.nativeEvent.isComposing || event.keyCode === 229;
const direction = getComputedStyle(event.currentTarget).direction === "rtl" ? "rtl" : "ltr";
const command = isCommandKey(event);
if (editing !== null) {
// While an input method composes, every key is its own.
if (composing) return;
const field = editor.current;
if (event.key === "Enter") {
event.preventDefault();
if (event.altKey && field instanceof HTMLTextAreaElement) {
field.setRangeText("\n", field.selectionStart, field.selectionEnd, "end");
return;
}
if (event.repeat) return;
if (commit(command) && !command) advance(editing, event.shiftKey ? "up" : "down");
} else if (event.key === "Tab") {
event.preventDefault();
if (commit()) advance(editing, event.shiftKey ? "left" : "right");
} else if (event.key === "Escape") {
// Abandoned here, before anything that contains the grid hears it.
event.preventDefault();
event.stopPropagation();
endEdit();
} else if (event.key === "F2") {
event.preventDefault();
setEdit({ ...editing, mode: editing.mode === "typing" ? "caret" : "typing" });
} else if (editing.mode === "typing" && !event.altKey) {
// Begun by typing over the cell, an edit ends with the arrow that moves on from it.
const move = gridMoveForKey({ key: event.key, command }, direction);
if (move === null || event.key === "Home" || event.key === "End") return;
event.preventDefault();
if (commit()) moveBy(move, event.shiftKey);
}
return;
}
// An input method's first key begins the edit by itself, in the field.
if (composing) return;
// The Menu key, and Shift with F10: not every browser follows them with the event a
// menu is asked for by, so it is made here — once the key itself has been heard out.
if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) {
event.preventDefault();
queueMicrotask(() =>
target.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true })),
);
return;
}
if (event.key === " " && (event.shiftKey || event.ctrlKey) && !event.metaKey && !event.altKey) {
event.preventDefault();
const cells = focusCell(selection) ?? active;
if (event.shiftKey) {
// The row — or rows — the selection is in, with the active cell where it was.
setSelection(wholeRows(active.row, cells.row, active));
} else {
const from = Math.min(active.column, cells.column);
const to = Math.max(active.column, cells.column);
setSelection([
{ anchor: { row: 0, column: from }, focus: { row: reach - 1, column: to } },
{ anchor: active, focus: active },
]);
}
return;
}
// Alt and Shift with an arrow up or down sort by the active cell's column: ascending, descending.
if (event.altKey && event.shiftKey && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
event.preventDefault();
sortBy(active.column, event.key === "ArrowUp" ? "ascending" : "descending");
return;
}
// Alt and Shift with an arrow to the side move the edge of the active cell's column.
if (event.altKey && event.shiftKey && (event.key === "ArrowLeft" || event.key === "ArrowRight")) {
event.preventDefault();
const wider = (event.key === "ArrowRight") === (direction === "ltr");
resize(active.column, (widths[active.column] ?? columnWidth) + (wider ? NUDGE : -NUDGE));
return;
}
// Up from the first row is the column's header, where the column is sorted, resized and moved.
if (event.key === "ArrowUp" && active.row === 0 && !event.altKey && !event.shiftKey && !command) {
event.preventDefault();
headerControl(columns[active.column]!.key)?.focus();
return;
}
// With Alt, the down arrow is the list button's: it opens the list.
const move = event.altKey ? null : gridMoveForKey({ key: event.key, command }, direction);
if (move !== null) {
event.preventDefault();
moveBy(move, event.shiftKey);
return;
}
if (command) {
const key = event.key.toLowerCase();
if (key === "a") {
event.preventDefault();
setSelection(selectAll(selection, reach, columnCount));
} else if (key === "backspace" || key === "delete") {
// With the command key, whole rows that are selected are taken out, not emptied.
if (removeRows()) event.preventDefault();
} else if ((key === "d" || key === "r") && !event.shiftKey && !readOnly) {
// Down from the first row of the selection, or across from its first column.
event.preventDefault();
fillFrom(key === "d" ? "rows" : "columns");
} else if (key === "z" || key === "y") {
event.preventDefault();
if (readOnly) return;
if (key === "y" || event.shiftKey) restore(history.redo(), true);
else restore(history.undo(), false);
}
return;
}
if (event.altKey) return;
const kind = columns[active.column]!.schema.kind;
if (event.key === "Enter" || event.key === "F2") {
event.preventDefault();
// A key held down opens one cell, not each in turn.
if (!event.repeat) beginEdit(active, "caret");
} else if (event.key === "Delete" || event.key === "Backspace") {
event.preventDefault();
if (!readOnly) clear(selection.map(boundsOf));
} else if (event.key === "Escape") {
// With more than one cell selected, Escape leaves the active one; otherwise it is not the grid's.
const only = selection.length === 1 && isCellSelected(selectCell(active), ...farCorner(selection));
if (only) return;
event.preventDefault();
event.stopPropagation();
setSelection(selectCell(active));
} else if (
target === focusTarget() &&
target.getAttribute("role") === "gridcell" &&
kind !== "boolean" &&
kind !== "choice" &&
(Array.from(event.key).length === 1 || event.key === "Dead")
) {
// Where the cell holds no field until it is edited, a character brings one
// up before it is typed, so that it is the field's first.
beginEdit(active, "typing");
}
}
/** Sort by a column — the way given, or the next of ascending, descending and not at all — keeping the active cell on its record. */
function sortBy(column: number, direction?: GridSort["direction"] | null): void {
const target = columns[column];
if (target === undefined || !commit()) return;
const current = sort?.key === target.key ? sort.direction : null;
const wanted =
direction === undefined
? current === null
? "ascending"
: current === "ascending"
? "descending"
: null
: direction === current
? null
: direction;
const next = wanted === null ? null : { key: target.key, direction: wanted };
const after = orderFor(rows, next);
if (active !== null && active.row < rowCount) {
const record = recordAt(active.row);
const row = after === null ? record : placesIn(after)[record]!;
select(selectCell({ row, column: active.column }));
}
answered.current = { rows, sort: next, orderFor };
orderNow.current = after;
setOrder(after);
setSort(next);
announce(messages.sorted(target.label, wanted));
}
/** The control a column's header takes focus on: the button that sorts by it. */
const headerControl = useCallback(
(key: string) =>
scroller.current?.querySelector<HTMLElement>(
`[role="columnheader"][data-key="${CSS.escape(key)}"] [data-sort]`,
) ?? null,
[],
);
// The column whose header is to have focus once the columns are drawn in their new order.
const headerToFocus = useRef({ key: "", among: columns });
useLayoutEffect(() => {
const { key, among } = headerToFocus.current;
// Not before the order has changed: an owner that holds it may take a moment to say so.
if (key === "" || among === columns) return;
headerToFocus.current = { key: "", among: columns };
headerControl(key)?.focus();
});
/** Move a column to another place among the columns, and say where it now is. Returns whether it moved. */
function moveColumn(from: number, to: number): boolean {
const column = columns[from];
const next = moveItem(columns, from, to);
if (column === undefined || next === columns || !commit()) return false;
tabOrigin.current = null;
setColumnOrder(next.map((moved) => moved.key));
announce(messages.moved(column.label, next.indexOf(column) + 1, columnCount));
return true;
}
// The header being dragged to another place: from where, where the press was, and where it would land.
const columnDrag = useRef<{ from: number; x: number; to: number | null } | null>(null);
const [landing, setLanding] = useState<{ readonly from: number; readonly to: number } | null>(null);
// The click that ends a drag is not a press on the header.
const draggedColumn = useRef(false);
// While a header is held near an edge of the view, the columns scroll that way: where the pointer is, the frame
// to come, and when the last one scrolled.
const columnScroll = useRef({ frame: 0, x: 0, at: 0 });
useEffect(() => () => cancelAnimationFrame(columnScroll.current.frame), []);
/** Where a header held at `x` would put its column. */
function aimColumn(x: number): void {
const drag = columnDrag.current;
const row = scroller.current?.firstElementChild;
if (drag === null || !(row instanceof HTMLElement)) return;
// Along the columns from where they start, which right to left is from the right.
const box = row.getBoundingClientRect();
const rtl = getComputedStyle(row).direction === "rtl";
const to = landingPlace(starts, drag.from, (rtl ? box.right - x : x - box.left) - headerWidth);
if (to !== drag.to) {
drag.to = to;
setLanding({ from: drag.from, to });
}
}
/** The columns scrolled towards a header held near an edge, by the time that has passed, not by the frame. */
function scrollColumns(time: number): void {
const held = columnScroll.current;
held.frame = 0;
const element = scroller.current;
const drag = columnDrag.current;
if (element === null || drag === null || drag.to === null) return;
const box = element.getBoundingClientRect();
const rtl = getComputedStyle(element).direction === "rtl";
// The row headers stay over the start of the view: the edge there is past them.
const left = box.left + (rtl ? 0 : headerWidth) + COLUMN_EDGE;
const right = box.left + element.clientWidth - (rtl ? headerWidth : 0) - COLUMN_EDGE;
let dx = held.x < left ? held.x - left : held.x > right ? held.x - right : 0;
// A header taken up near an edge scrolls nothing until it is dragged on towards it.
if (Math.sign(dx) !== Math.sign(held.x - drag.x)) dx = 0;
if (dx === 0) {
held.at = 0;
return;
}
const frames = held.at === 0 ? 1 : Math.min(Math.max(time - held.at, 0), 64) / FRAME;
held.at = time;
element.scrollBy(Math.sign(dx) * Math.min(Math.abs(dx) + 4, 40) * frames, 0);
aimColumn(held.x);
held.frame = requestAnimationFrame(scrollColumns);
}
function endColumnDrag(move: boolean): void {
const drag = columnDrag.current;
columnDrag.current = null;
cancelAnimationFrame(columnScroll.current.frame);
columnScroll.current = { frame: 0, x: 0, at: 0 };
setLanding(null);
if (move && drag !== null && drag.to !== null) moveColumn(drag.from, drag.to);
}
function dragColumn(event: PointerEvent<HTMLDivElement>): void {
const drag = columnDrag.current;
if (drag === null) return;
if ((event.buttons & 1) === 0) {
endColumnDrag(false);
return;
}
if (drag.to === null && Math.abs(event.clientX - drag.x) < DRAG_SLOP) return;
draggedColumn.current = true;
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.setPointerCapture(event.pointerId);
}
aimColumn(event.clientX);
// Near or past an edge of the view, the columns come to the pointer, at a pace of their own while it is held there.
const held = columnScroll.current;
held.x = event.clientX;
if (held.frame === 0) {
held.at = 0;
held.frame = requestAnimationFrame(scrollColumns);
}
}
/** Keys on a column's header: along the headers, back to the cells, and what is done to the column. */
function handleHeaderKey(event: KeyboardEvent<HTMLDivElement>, column: number): void {
const forward = getComputedStyle(event.currentTarget).direction === "rtl" ? -1 : 1;
const side = event.key === "ArrowRight" ? forward : event.key === "ArrowLeft" ? -forward : 0;
const key = columns[column]?.key;
if (key === undefined || isCommandKey(event)) return;
const to = (index: number) => {
const next = columns[Math.min(Math.max(index, 0), columnCount - 1)];
if (next !== undefined) headerControl(next.key)?.focus();
};
if (event.altKey) {
if (side !== 0) {
event.preventDefault();
// With Shift the column's edge moves; without, the column itself, and focus with it.
if (event.shiftKey) resize(column, (widths[column] ?? columnWidth) + side * NUDGE);
else if (moveColumn(column, column + side)) headerToFocus.current = { key, among: columns };
} else if (event.shiftKey && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
event.preventDefault();
sortBy(column, event.key === "ArrowUp" ? "ascending" : "descending");
// Focus stays on the header, wherever the active cell's record has gone.
refocus.current = false;
}
return;
}
if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) {
event.preventDefault();
const asked = event.target;
queueMicrotask(() =>
asked.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true })),
);
} else if (side !== 0) {
event.preventDefault();
to(column + side);
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
to(event.key === "Home" ? 0 : columnCount - 1);
} else if (event.key === "ArrowDown" || event.key === "Escape") {
// Down into the column's first cell; Escape, back to the cell that was left.
event.preventDefault();
event.stopPropagation();
// With no cells to go to, back to the grid itself.
if (active === null) {
scroller.current?.focus({ preventScroll: true });
return;
}
const cell = event.key === "Escape" && active !== null ? active : { row: 0, column };
takeFocus(cell);
select(selectCell(cell));
} else if (event.key === "ArrowUp") {
event.preventDefault();
} else if (event.key === "Tab" && !event.shiftKey) {
// Onward from the grid, not into it: the grid is one stop, and this is it.
focusTarget()?.focus({ preventScroll: true });
}
}
function resize(column: number, width: number): void {
const key = columns[column]?.key;
const next = clampWidth(width);
if (key !== undefined && next !== widths[column]) setOwnWidths({ ...ownWidths, [key]: next });
}
/** The width that shows the whole of a column's name and of what its drawn cells hold. */
function fitWidth(column: number): number {
const element = scroller.current;
const context = document.createElement("canvas").getContext("2d");
const target = columns[column];
if (element === null || context === null || target === undefined) return widths[column] ?? columnWidth;
const header = element.querySelector<HTMLElement>(
`[role="columnheader"][aria-colindex="${column + (rowHeaders ? 2 : 1)}"]`,
);
const cell = element.querySelector<HTMLElement>(`[role="gridcell"][data-column="${column}"]`);
let widest = 0;
if (header !== null) {
context.font = getComputedStyle(header).font;
widest = context.measureText(target.label).width;
}
if (cell !== null) {
context.font = getComputedStyle(cell).font;
// The rows in view, and as many beyond as can be measured without being felt.
const last = Math.min(rowCount, Math.max(view.end, 2000));
for (let row = 0; row < last; row += 1) {
widest = Math.max(widest, context.measureText(target.codec.format(rows[row]?.[target.key])).width);
}
}
// Room for the cell's padding and its edge, and for a checkbox where that is what it holds.
return Math.ceil(Math.max(widest, target.schema.kind === "boolean" ? 16 : 0)) + 18;
}
// The column whose edge is being dragged, where the drag began, and its width then.
const resizing = useRef<{ column: number; x: number; width: number; sign: 1 | -1 } | null>(null);
function beginResize(event: PointerEvent<HTMLDivElement>, column: number): void {
if (event.button !== 0) return;
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
resizing.current = {
column,
x: event.clientX,
width: widths[column] ?? columnWidth,
// Right to left, a column grows as its edge is dragged leftward.
sign: getComputedStyle(event.currentTarget).direction === "rtl" ? -1 : 1,
};
}
function resizeTo(event: PointerEvent<HTMLDivElement>): void {
const drag = resizing.current;
if (drag !== null) resize(drag.column, drag.width + drag.sign * (event.clientX - drag.x));
}
function endResize(): void {
resizing.current = null;
}
const dragging = useRef(false);
// The row a drag down the row headers began in, while one is under way.
const draggingRows = useRef<number | null>(null);
// What pressed last: a click does not reliably say, so the press is remembered.
const pointerType = useRef("mouse");
const pointer = useRef({ x: 0, y: 0 });
const scrolling = useRef(0);
// Where a drag began; from the pointer to the place a drag by a handle stands for;
// and how far inside an edge the drag begins to scroll.
const origin = useRef({ x: 0, y: 0 });
const aim = useRef({ x: 0, y: 0 });
const edge = useRef(0);
// The pointer whose drag it is.
const dragPointer = useRef(-1);
function stopDrag(): void {
dragging.current = false;
setHolding(false);
cancelAnimationFrame(scrolling.current);
scrolling.current = 0;
}
function cancelFill(): void {
fillSource.current = null;
fillNow.current = null;
setFillPreview(null);
}
/** A drag let go: a fill that was dragged out is made. */
function endDrag(): void {
const source = fillSource.current;
const target = fillNow.current;
cancelFill();
stopDrag();
if (source !== null && target !== null) fillCells(source, target, true);
}
/**
* Begin a drag by a handle at a corner of the selection. What the drag points
* at is not the pointer but a place `inward` of the corner the handle marks:
* the corner's own cell, until the pointer has gone that far beyond it.
*/
function beginHandleDrag(
event: PointerEvent<HTMLDivElement>,
corner: Corner,
inward: { x: number; y: number },
inset: number,
): void {
event.stopPropagation();
const box = event.currentTarget.getBoundingClientRect();
const sign =
(corner === "start" ? 1 : -1) * (getComputedStyle(event.currentTarget).direction === "rtl" ? -1 : 1);
aim.current = {
x: box.left + box.width / 2 + sign * inward.x - event.clientX,
y: box.top + box.height / 2 + (corner === "start" ? 1 : -1) * inward.y - event.clientY,
};
pressed.current = null;
pointerType.current = event.pointerType;
draggingRows.current = null;
dragPointer.current = event.pointerId;
dragging.current = true;
edge.current = inset;
pointer.current = { x: event.clientX, y: event.clientY };
origin.current = pointer.current;
}
/** A finger on one of the selection's two handles redraws the selection from the other. */
function beginCorner(event: PointerEvent<HTMLDivElement>, corner: Corner): void {
const bounds = clipboardBounds(selection);
if (bounds === null || !commit()) return;
refocus.current = true;
select(
dragCorner(
bounds,
corner,
corner === "start"
? { row: bounds.top, column: bounds.left }
: { row: bounds.bottom, column: bounds.right },
),
);
setHolding(true);
// The selection takes in the next row once the finger is half a row into it.
beginHandleDrag(event, corner, { x: NARROWEST / 2, y: height / 2 }, TOUCH_EDGE);
}
function beginFill(event: PointerEvent<HTMLDivElement>): void {
const bounds = clipboardBounds(selection);
if (event.button !== 0 || bounds === null || readOnly || !commit()) return;
fillSource.current = bounds;
beginHandleDrag(event, "end", { x: 3, y: 3 }, 0);
}
useEffect(() => () => cancelAnimationFrame(scrolling.current), []);
// When the grid last scrolled itself along under a drag; 0 before the first frame of one.
const scrolledAt = useRef(0);
const latest = useRef(selection);
useLayoutEffect(() => {
latest.current = selection;
});
/** What is at a point, beneath whatever handle lies over it. */
function beneath(x: number, y: number): Element | null {
const layer = handles.current;
return document.elementsFromPoint(x, y).find((element) => layer?.contains(element) !== true) ?? null;
}
const cellUnder = (x: number, y: number): Cell | null => cellAt(beneath(x, y));
function extendToPointer(): void {
const element = scroller.current;
if (element === null) return;
const box = element.getBoundingClientRect();
// Held within the cells, so that a pointer beyond the edge selects up to it.
const x = Math.min(
Math.max(pointer.current.x + aim.current.x, box.left + headerWidth + 1),
box.left + element.clientWidth - 1,
);
const y = Math.min(
Math.max(pointer.current.y + aim.current.y, box.top + height + 1),
box.top + element.clientHeight - 1,
);
const cell = cellUnder(x, y);
if (cell === null) return;
if (fillSource.current !== null) {
const target = fillTarget(fillSource.current, cell);
const was = fillNow.current;
fillNow.current = target;
if (was?.axis !== target?.axis || !sameBounds(was?.bounds, target?.bounds)) setFillPreview(target);
return;
}
const from = draggingRows.current;
const at = activeCell(latest.current);
if (from === null || at === null) setSelection(extendTo(latest.current, cell));
else setSelection(wholeRows(from, cell.row, at));
}
/** While a drag is held beyond an edge, the grid scrolls that way, faster the further out. */
function autoScroll(time: number): void {
scrolling.current = 0;
const element = scroller.current;
if (element === null || !dragging.current) return;
const box = element.getBoundingClientRect();
const { x, y } = pointer.current;
const inset = edge.current;
const top = box.top + height + inset;
let dy = y < top ? y - top : y > box.bottom - inset ? y - (box.bottom - inset) : 0;
let dx =
x < box.left + inset ? x - (box.left + inset) : x > box.right - inset ? x - (box.right - inset) : 0;
if (inset > 0) {
// A drag that began near an edge scrolls only once it has gone on towards it.
if (Math.sign(dy) !== Math.sign(y - origin.current.y)) dy = 0;
if (Math.sign(dx) !== Math.sign(x - origin.current.x)) dx = 0;
}
if (dx === 0 && dy === 0) return;
// By the time that has passed, not by the frame: a slow machine scrolls as fast, in fewer steps.
const frames =
scrolledAt.current === 0 ? 1 : Math.min(Math.max(time - scrolledAt.current, 0), 64) / FRAME;
scrolledAt.current = time;
element.scrollBy(
Math.sign(dx) * Math.min(Math.abs(dx), 40) * frames,
Math.sign(dy) * Math.min(Math.abs(dy), 40) * frames,
);
extendToPointer();
scrolling.current = requestAnimationFrame(autoScroll);
}
function handlePointerDown(event: PointerEvent<HTMLDivElement>): void {
pointerType.current = event.pointerType;
pressed.current = pressedAt(event.target);
aim.current = { x: 0, y: 0 };
edge.current = 0;
selecting.current = event.pointerType === "mouse" && (event.shiftKey || isCommandKey(event));
const headed = headerRowAt(event.target);
if (headed !== null && event.button === 0 && event.pointerType !== "touch") {
// A press on a row's header selects the row; with Shift, the rows from the active cell's to it.
if (active === null || !commit()) return;
refocus.current = true;
const at = event.shiftKey ? active : { row: headed, column: active.column };
select(wholeRows(event.shiftKey ? active.row : headed, headed, at));
draggingRows.current = at.row;
dragPointer.current = event.pointerId;
dragging.current = true;
pointer.current = { x: event.clientX, y: event.clientY };
return;
}
draggingRows.current = null;
const cell = cellAt(event.target);
if (cell === null || isOwnControl(event.target)) return;
// A secondary press — on a Mac, Ctrl-click too — is for a context menu: it
// selects what it lands on only if that is outside the selection.
if (event.button === 2 || (event.button === 0 && event.ctrlKey && !isCommandKey(event))) {
if (!isCellSelected(selection, cell.row, cell.column) && commit()) select(selectCell(cell));
return;
}
// A finger selects when it lifts, so that a scroll which starts on a cell does not.
if (event.button !== 0 || event.pointerType === "touch") return;
// An edit that cannot be read is not left by pressing elsewhere.
if (!commit()) {
event.preventDefault();
return;
}
tabOrigin.current = null;
if (event.shiftKey) {
refocus.current = true;
select(extendTo(selection, cell));
} else {
takeFocus(cell);
select(isCommandKey(event) ? addCell(selection, cell) : selectCell(cell));
}
dragPointer.current = event.pointerId;
dragging.current = true;
pointer.current = { x: event.clientX, y: event.clientY };
}
function handlePointerMove(event: PointerEvent<HTMLDivElement>): void {
// Only the pointer that began the drag: a mouse lying over the grid is said to move
// when the grid scrolls under it, with no button down, and that is not the finger lifting.
if (!dragging.current || event.pointerId !== dragPointer.current) return;
if ((event.buttons & 1) === 0) {
cancelFill();
stopDrag();
return;
}
if (event.clientX === pointer.current.x && event.clientY === pointer.current.y) return;
pointer.current = { x: event.clientX, y: event.clientY };
// Once it moves, the drag is the grid's wherever the pointer goes.
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// A pointer that cannot be captured still drags, for as long as it is over the grid.
}
}
extendToPointer();
if (scrolling.current === 0) {
scrolledAt.current = 0;
scrolling.current = requestAnimationFrame(autoScroll);
}
}
function handleClick(event: MouseEvent<HTMLDivElement>): void {
const headed = headerRowAt(event.target);
if (headed !== null) {
// A finger selects a row by its header on the tap; a mouse did on the press.
if (pointerType.current === "mouse" && event.detail !== 0) return;
if (active !== null && commit()) {
refocus.current = true;
select(wholeRows(headed, headed, { row: headed, column: active.column }));
}
return;
}
// A handle that is tapped, not dragged, is not in the way of what is beneath it.
const handle = event.target instanceof Element && event.target.closest("[data-handle]") !== null;
const cell = handle ? cellUnder(event.clientX, event.clientY) : cellAt(event.target);
if (handle && cell === null) {
const under = beneath(event.clientX, event.clientY);
if (under instanceof HTMLElement && under !== event.currentTarget) under.click();
return;
}
if (cell === null || isOwnControl(event.target)) return;
const control =
event.target instanceof Element && event.target.closest('input[type="checkbox"], button') !== null;
// A mouse selected on the press. A click that no press came before is a
// finger's, a pen's, or assistive technology's.
if (pointerType.current === "mouse" && event.detail !== 0 && !handle) return;
pointerType.current = "mouse";
if (!commit()) return;
const same = active !== null && active.row === cell.row && active.column === cell.column;
// A tap on the selection — but for its active cell — asks what can be done with it.
if (!same && !control && isCellSelected(selection, cell.row, cell.column)) {
event.target.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: event.clientX,
clientY: event.clientY,
}),
);
return;
}
takeFocus(cell);
// A tap makes a cell active, and a tap on the active cell edits it.
if (same && !control) beginEdit(cell, "caret");
else select(selectCell(cell));
}
function handleMenuOpenChange(open: boolean): void {
setMenuOpen(open);
if (!open) return;
const at = pressed.current;
// Asked for over a column's header, or by a key while one has focus, it is the column's menu.
const header = document.activeElement?.closest<HTMLElement>('[role="columnheader"][data-key]');
const key =
at === null ? header?.dataset.key : at.kind === "column" ? columns[at.column]?.key : undefined;
setMenuColumn(key ?? null);
if (at === null || active === null) return;
// A menu acts on what it appears over: a rest on a cell outside the selection selects that cell,
// and one on a row's header, the row. Focus is the menu's now, and comes back when it closes.
if (at.kind === "cell" && !isCellSelected(selection, at.cell.row, at.cell.column)) {
setSelection(selectCell(at.cell));
} else if (at.kind === "row" && !wholeRowSelected(selection, at.row, columnCount)) {
setSelection(wholeRows(at.row, at.row, { row: at.row, column: active.column }));
}
}
// A menu that closes gives focus back to the active cell, which what it did may have moved.
const menu = useRef<HTMLDivElement>(null);
// The column the menu is about, when it is about one and not about the selection.
const [menuColumn, setMenuColumn] = useState<string | null>(null);
const handles = useRef<HTMLDivElement>(null);
const menuWasOpen = useRef(false);
useLayoutEffect(() => {
const closed = menuWasOpen.current && !menuOpen;
menuWasOpen.current = menuOpen;
if (!closed) return;
// Unless focus has gone somewhere of its own accord: a press outside the grid closes the menu too.
const focused = document.activeElement;
const elsewhere =
focused !== null &&
focused !== document.body &&
// A header that asked for the menu by a key has focus again.
(focused.closest('[role="columnheader"]') !== null ||
(scroller.current?.contains(focused) !== true && menu.current?.contains(focused) !== true));
if (elsewhere) return;
const target = focusTarget();
target?.focus({ preventScroll: true });
if (target === editor.current) editor.current?.select();
}, [menuOpen, focusTarget]);
function blockOf(bounds: Bounds): string[][] {
const block: string[][] = [];
for (let row = bounds.top; row <= bounds.bottom; row += 1) {
const line: string[] = [];
for (let column = bounds.left; column <= bounds.right; column += 1) {
line.push(columns[column]!.codec.format(rows[recordAt(row)]?.[columns[column]!.key]));
}
block.push(line);
}
return block;
}
/** The cells to copy, as text and as a table: the two forms a spreadsheet looks for. */
function copy(event: ClipboardEvent<HTMLDivElement>): Bounds | null {
const bounds = clipboardBounds(selection);
if (editing !== null || bounds === null || isOwnControl(event.target)) return null;
const block = blockOf(bounds);
event.clipboardData.setData("text/plain", formatTSV(block));
event.clipboardData.setData("text/html", tableOf(block));
event.preventDefault();
return bounds;
}
function paste(event: ClipboardEvent<HTMLDivElement>): void {
const bounds = clipboardBounds(selection);
if (editing !== null || bounds === null || readOnly || isOwnControl(event.target)) return;
event.preventDefault();
// A clipboard that holds no text — an image, a file — has nothing for a cell.
if (event.clipboardData.types.includes("text/plain")) {
pasteText(event.clipboardData.getData("text/plain"));
}
}
function pasteText(pasted: string): void {
const bounds = clipboardBounds(selection);
if (bounds === null || readOnly) return;
const block = parseTSV(pasted);
const blockRows = block.length;
const blockColumns = Math.max(...block.map((line) => line.length));
// A block is repeated over a selection it divides evenly; otherwise it lands once, at the corner.
const tiles =
(bounds.bottom - bounds.top + 1) % blockRows === 0 &&
(bounds.right - bounds.left + 1) % blockColumns === 0;
// Where records can be added, a block that runs past the last one makes more to hold it.
const last = tiles ? bounds.bottom : bounds.top + blockRows - 1;
const bottom = canInsert && !tiles ? last : Math.min(last, viewRows - 1);
const right = Math.min(tiles ? bounds.right : bounds.left + blockColumns - 1, columnCount - 1);
const changes: CellChange[] = [];
let unread = 0;
for (let row = bounds.top; row <= bottom; row += 1) {
const line = block[(row - bounds.top) % blockRows]!;
for (let column = bounds.left; column <= right; column += 1) {
const text = line[(column - bounds.left) % blockColumns];
// A line shorter than the block has nothing for this cell.
if (text === undefined) continue;
const result = columns[column]!.codec.read(text);
if (result.ok) changes.push(changeOf({ row, column }, result.value));
else if (result.reason === "empty") changes.push(changeOf({ row, column }, null));
// Text that is not a value of the column's kind leaves the cell as it was.
else unread += 1;
}
}
const { done, refused } = apply(changes);
select(extendTo(selectCell({ row: bounds.top, column: bounds.left }), { row: bottom, column: right }));
announce([messages.pasted(done, unread), refusalOf(refused)].filter(Boolean).join(" "));
}
// What a clipboard's answer is acted on with, once it comes: by then the rows may be others.
const afterWait = useRef({ pasteText, clear });
useLayoutEffect(() => {
afterWait.current = { pasteText, clear };
});
/** Copy, or cut, from the menu: with no clipboard event to carry the cells, the browser is asked, and may refuse. */
function copyFromMenu(cut: boolean): void {
const bounds = clipboardBounds(selection);
if (bounds === null) return;
const block = blockOf(bounds);
const cells = block.length * (block[0]?.length ?? 0);
void writeClipboard(formatTSV(block), tableOf(block)).then((written) => {
// What is cleared is what was copied, and only once it has been.
if (written && cut) afterWait.current.clear([bounds]);
announce(written ? messages.copied(cells) : messages.copyDenied);
});
}
function pasteFromMenu(): void {
void readClipboard().then((held) => {
if (held === null) announce(messages.pasteDenied);
else if (held !== "") afterWait.current.pasteText(held);
});
}
// The one rectangle the selection is: what its handles hold, and what the menu's commands act on.
const block = clipboardBounds(selection);
let canChange = false;
if (block !== null && !readOnly) {
for (let column = block.left; column <= block.right; column += 1) {
canChange ||= columns[column]?.fixed === false;
}
}
/** A field's label, for a lock to be named by. */
function labelOf(key: string): string {
const column = columns.find((candidate) => candidate.key === key);
if (column !== undefined) return column.label;
const field = schema.fields.find((candidate) => candidate.key === key);
return field === undefined ? key : (resolve(field.schema).label ?? key);
}
/**
* The cells of a block whose locks the menu changes — every column that is
* computed, and the rest where the owner works with locks — the locks among
* them, and what can be done to them: what a single cell's menu offers,
* offered for every cell it applies to.
*/
function lockScope(bounds: Bounds | null) {
if (bounds === null || readOnly) return null;
const bottom = Math.min(bounds.bottom, rowCount - 1);
const lockable = columns
.slice(bounds.left, bounds.right + 1)
.filter((column) => !column.fixed && (column.computed || offersLocks));
if (bottom < bounds.top || lockable.length === 0) return null;
// The locks within it, found from the locks and not from every cell: there are fewer.
const byKey = new Map(lockable.map((column) => [column.key, column]));
const places = order === null ? null : placesIn(order);
let recordOf: (key: string) => number | undefined;
if (rowKey === undefined) {
recordOf = (key) => {
const at = Number(key);
return Number.isInteger(at) && at >= 0 && at < rowCount && String(at) === key ? at : undefined;
};
} else {
const keyed = new Map(rows.map((row, at) => [rowKey(row, at), at]));
recordOf = (key) => keyed.get(key);
}
const locked: Array<{
readonly record: number;
readonly column: Column;
readonly lock: Lock;
readonly key: string;
}> = [];
for (const [key, lock] of locks) {
const split = key.lastIndexOf("\u001f");
const column = byKey.get(key.slice(split + 1));
const record = column === undefined ? undefined : recordOf(key.slice(0, split));
if (column === undefined || record === undefined) continue;
const row = places === null ? record : places[record]!;
if (row >= bounds.top && row <= bottom) locked.push({ record, column, lock, key });
}
const records = bottom - bounds.top + 1;
const held = locked.filter((found) => found.lock === "held").length;
const frozenOther = locked.filter((found) => found.lock === "frozen" && !found.column.computed).length;
const computed = lockable.filter((column) => column.computed).length;
const computedLocked = locked.filter((found) => found.column.computed).length;
return {
top: bounds.top,
bottom,
lockable,
locked,
hold: records * lockable.length - held - frozenOther > 0,
freeze: records * computed - computedLocked > 0,
unlock: locked.length > 0,
// Unfreezing, where everything it unlocks is computed and frozen: it computes them again.
unfreeze:
locked.length > 0 && locked.every((found) => found.lock === "frozen" && found.column.computed),
};
}
/** Hold, or freeze, every cell of the scope that can be: one action, undone as one. */
function lockCells(scope: NonNullable<ReturnType<typeof lockScope>>, lock: Lock): void {
const next = new Map(locks);
for (let row = scope.top; row <= scope.bottom; row += 1) {
const record = recordAt(row);
for (const column of scope.lockable) {
const key = lockKey(keyOf(record), column.key);
const current = locks.get(key);
const may =
lock === "held"
? current === undefined || (current === "frozen" && column.computed)
: current === undefined && column.computed;
if (may) next.set(key, lock);
}
}
perform([], undefined, next);
}
/**
* Take the locks off the scope's cells. A computed one is computed again —
* which is how a frozen one is unfrozen — unless that would change a held
* value, which then refuses it: those keep their locks, and are said.
*/
function unlockCells(scope: NonNullable<ReturnType<typeof lockScope>>): void {
const { plan } = derivationsOf(schema);
const next = new Map(locks);
const refused: Array<{ readonly column: Column; readonly by: string }> = [];
const cells: CellChange[] = [];
const byRecord = Map.groupBy(scope.locked, (found) => found.record);
for (const [record, found] of byRecord) {
const was = rows[record];
if (was === undefined) continue;
let now = was;
for (const { column, key } of found) {
const result = unlockRecord(plan, now, column.key, (field) =>
next.get(lockKey(keyOf(record), field)),
);
if (result.refusedBy !== undefined) refused.push({ column, by: result.refusedBy });
else {
next.delete(key);
now = result.record;
}
}
for (const key of [...plan.order, ...plan.cyclic]) {
if (!Object.is(now[key], was[key])) {
cells.push({ row: record, key, value: now[key] ?? null, previous: was[key] ?? null });
}
}
}
perform([{ kind: "cells", cells }], undefined, next.size === locks.size ? locks : next);
const [first] = refused;
if (first !== undefined && refused.length === 1) {
announce(messages.problem(first.column.label, messages.refused(labelOf(first.by))));
} else if (refused.length > 1) {
announce(messages.refusedBy(refused.length, [...new Set(refused.map(({ by }) => labelOf(by)))]));
}
}
// Counted only while the menu is there to say them.
const rowsIn = menuOpen ? rowsOf(selection, rowCount) : 0;
const series = menuOpen && canChange ? seriesOf(block) : null;
const rowsAbove = block === null ? 1 : recordsIn(block, rowCount);
// The rows in view, and the active row too, so the active cell always exists
// — in order, so that a row never moves in the document and takes focus with it.
const drawn: number[] = [];
if (active !== null && active.row < view.first) drawn.push(active.row);
for (let index = view.first; index < view.end; index += 1) drawn.push(index);
if (active !== null && active.row >= view.end) drawn.push(active.row);
// Rows sit where they are in the list, moved by however far the scroll position is from there.
const shift = scrollTop - offset;
const ours = {
onScroll: (event: { currentTarget: HTMLDivElement }) => {
setScrollTop(event.currentTarget.scrollTop);
// Right to left, a scroll position counts down from zero.
setScrollStart(Math.abs(event.currentTarget.scrollLeft));
},
onKeyDownCapture: handleKeyDown,
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: (event: PointerEvent<HTMLDivElement>) => {
if (event.pointerId === dragPointer.current) endDrag();
},
onPointerCancel: (event: PointerEvent<HTMLDivElement>) => {
if (event.pointerId !== dragPointer.current) return;
cancelFill();
stopDrag();
},
// The press must not move focus to the grid itself: it goes to the active cell's control.
onMouseDown: (event: MouseEvent<HTMLDivElement>) => {
if (!isOwnControl(event.target)) event.preventDefault();
},
onClick: handleClick,
onDoubleClick: (event: MouseEvent<HTMLDivElement>) => {
const cell = cellAt(event.target);
if (cell === null || isOwnControl(event.target)) return;
if (event.target instanceof Element && event.target.closest('input[type="checkbox"]') !== null) return;
if (commit()) beginEdit(cell, "caret");
},
onBlur: (event: { currentTarget: HTMLDivElement; relatedTarget: EventTarget | null }) => {
// Leaving the grid commits what was being typed, if it can be read; if
// not, it stays as typed, marked, for the person to come back to.
const next = event.relatedTarget;
if (next instanceof Node && event.currentTarget.contains(next)) return;
if (document.hasFocus() && next !== null) commit();
},
onCopy: (event: ClipboardEvent<HTMLDivElement>) => void copy(event),
onCut: (event: ClipboardEvent<HTMLDivElement>) => {
if (readOnly) return;
// What is cleared is exactly what was copied.
const bounds = copy(event);
if (bounds !== null) clear([bounds]);
},
onPaste: paste,
};
const inView = (row: number) => row >= view.first && row <= view.end;
// The column the menu is about, where it is, and its neighbours.
const aboutAt = menuColumn === null ? -1 : columns.findIndex((column) => column.key === menuColumn);
const about = columns[aboutAt];
const locking = menuOpen && about === undefined ? lockScope(block) : null;
const before = columns[aboutAt - 1];
const after = about === undefined ? undefined : columns[aboutAt + 1];
const reachPast = coarse ? TOUCH_TARGET / 2 : 0;
/** Whether a corner of the cells is in the view, and not scrolled beneath the headers. */
const cornerShown = (row: number, column: number) =>
row * height >= offset - 1 &&
row * height <= offset + bodyViewport + 1 &&
starts[column]! >= scrollStart - 1 &&
starts[column]! <= scrollStart + viewportWidth - headerWidth + 1;
return (
<ContextMenu onOpenChange={handleMenuOpenChange}>
<ContextMenuTrigger
// While a cell is being edited, the menu is its field's own.
disabled={editing !== null}
role="grid"
// A stop in the tab sequence itself only until it has a cell to hand focus to.
tabIndex={active === null ? 0 : -1}
aria-rowcount={viewRows + 1}
aria-colcount={columnCount + (rowHeaders ? 1 : 0)}
aria-multiselectable
aria-readonly={readOnly || undefined}
// The handlers read refs when they run, not as they are merged here.
// oxlint-disable-next-line react/refs
{...mergeProps(ours, props)}
ref={mergeRefs(scroller, ref)}
className={cn(
"group/grid relative overflow-auto overscroll-contain rounded-lg border border-border-muted bg-surface outline-none select-none",
"focus-visible:outline-2 focus-visible:outline-focus-ring",
className,
)}
>
<div role="row" aria-rowindex={1} className="sticky top-0 z-30 flex w-max" style={{ height }}>
{rowHeaders && (
// Every cell is selected from the keyboard by the command key with A.
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
<div
role="columnheader"
aria-colindex={1}
data-corner=""
style={{ width: HEADER }}
onClick={() => {
if (!extent || !commit()) return;
refocus.current = true;
select(selectAll(selection, reach, columnCount));
}}
className="sticky start-0 z-10 shrink-0 border-e border-b border-border-muted bg-surface-muted hover:bg-surface-strong active:bg-surface-strong"
>
<span className="sr-only">{messages.selectAll}</span>
</div>
)}
{columns.map((column, index) => {
const said =
column.problem ??
(column.computed && column.schema.formula !== undefined
? messages.computed(column.schema.formula)
: undefined);
const aboutId = said === undefined ? undefined : `${gridId}-about-${index}`;
return (
// A column is selected from the keyboard by Ctrl with Space in one of its cells.
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
<div
key={column.key}
role="columnheader"
data-column={index}
data-key={column.key}
data-computed={column.computed ? "" : undefined}
data-formula-problem={column.problem === undefined ? undefined : ""}
title={said}
data-dragging={landing?.from === index ? "" : undefined}
id={`${gridId}-header-${index}`}
aria-colindex={index + (rowHeaders ? 2 : 1)}
// Named by its label alone, not by the edge that resizes it.
aria-labelledby={`${gridId}-label-${index}`}
aria-sort={sort?.key === column.key ? sort.direction : "none"}
style={{ width: widths[index] }}
// A mouse that presses a header and drags it takes the column to another place.
onPointerDown={(event) => {
draggedColumn.current = false;
if (event.button === 0 && event.pointerType === "mouse") {
columnDrag.current = { from: index, x: event.clientX, to: null };
}
}}
onPointerMove={dragColumn}
onPointerUp={() => endColumnDrag(true)}
onPointerCancel={() => endColumnDrag(false)}
// On the click, so that a scroll which starts on a header does not select.
onClick={(event) => {
if (draggedColumn.current) {
draggedColumn.current = false;
return;
}
if (viewRows === 0 || !commit()) return;
refocus.current = true;
// With Shift, the columns from the active cell's to this one.
const from = event.shiftKey && active !== null ? active.column : index;
const at = active !== null && event.shiftKey ? active : { row: 0, column: index };
setSelection([
{ anchor: { row: 0, column: from }, focus: { row: reach - 1, column: index } },
{ anchor: at, focus: at },
]);
}}
data-kind={column.schema.kind}
className="group/header @container/header relative flex shrink-0 items-center gap-1 border-e border-b border-border-muted bg-surface-muted px-2 text-xs font-medium text-fg-muted hover:bg-surface-strong active:bg-surface-strong aria-[sort=ascending]:text-fg-strong aria-[sort=descending]:text-fg-strong data-dragging:bg-surface-strong data-dragging:text-fg-strong data-[kind=number]:justify-end forced-colors:data-dragging:bg-[Highlight] forced-colors:data-dragging:text-[HighlightText] data-dragging:forced-color-adjust-none"
>
<span id={`${gridId}-label-${index}`} className="min-w-0 truncate">
{column.label}
</span>
{/* For the eye: the header's description says it in full. */}
{column.problem !== undefined ? (
<svg
aria-hidden
data-problem-mark=""
viewBox="0 0 16 16"
className="size-3.5 shrink-0 text-danger"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
>
<circle cx="8" cy="8" r="6.25" />
<path d="M8 4.75v3.75M8 11.25v.01" />
</svg>
) : (
column.computed && (
<span
aria-hidden
data-computed-mark=""
className="shrink-0 rounded-sm border border-border-muted px-1 font-normal whitespace-nowrap"
>
{/* The word where the column has room for it beside its name, and a sign where it has not. */}
<span className="hidden @min-[10rem]/header:inline">{messages.computedMark}</span>
<span className="font-serif italic @min-[10rem]/header:hidden">ƒ</span>
</span>
)
)}
{aboutId !== undefined && (
<span id={aboutId} hidden>
{said}
</span>
)}
{/* Sorts by the column: ascending, then descending, then not at all. It is what the header
takes focus on, reached by the up arrow from the column's first cell. */}
<button
type="button"
tabIndex={-1}
aria-label={messages.sortBy(column.label)}
aria-describedby={aboutId}
data-sort=""
onClick={(event) => {
event.stopPropagation();
if (draggedColumn.current) {
draggedColumn.current = false;
return;
}
refocus.current = true;
sortBy(index);
// Pressed by a key, it keeps focus; pressed by a pointer, focus is the active cell's.
if (event.detail === 0) refocus.current = false;
}}
className="grid size-5 shrink-0 place-items-center rounded opacity-0 outline-none focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-focus-ring focus-visible:outline-solid group-hover/header:opacity-100 group-aria-[sort=ascending]/header:opacity-100 group-aria-[sort=descending]/header:opacity-100 hover:bg-surface-strong focus-visible:opacity-100 pointer-coarse:size-8 pointer-coarse:opacity-100"
>
<svg
aria-hidden
viewBox="0 0 16 16"
className="size-3.5 group-aria-[sort=descending]/header:rotate-180"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
>
<path d="M8 12.5v-9M4.5 7 8 3.5 11.5 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{/* The column's edge: dragged, it resizes the column; pressed twice, it fits it to what it holds.
From the keyboard a column is resized by Alt and Shift with an arrow in one of its cells. */}
<div
role="separator"
aria-orientation="vertical"
aria-label={messages.resize(column.label)}
aria-valuenow={widths[index]}
aria-valuemin={NARROWEST}
aria-valuemax={WIDEST}
tabIndex={-1}
onKeyDown={(event) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
event.stopPropagation();
const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
resize(
index,
(widths[index] ?? columnWidth) +
((event.key === "ArrowRight") !== rtl ? NUDGE : -NUDGE),
);
}}
onPointerDown={(event) => beginResize(event, index)}
onPointerMove={resizeTo}
onPointerUp={endResize}
onPointerCancel={endResize}
// Neither the press nor the click that ends a drag is a press on the header.
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => {
event.stopPropagation();
resize(index, fitWidth(index));
}}
className="absolute -end-1 top-0 z-10 h-full w-2 cursor-col-resize touch-none hover:bg-focus-ring/50 active:bg-focus-ring pointer-coarse:-end-3 pointer-coarse:w-6"
/>
</div>
);
})}
{/* Where a header being dragged would put its column. */}
{landing !== null && landing.to !== landing.from && (
<div
aria-hidden
data-landing=""
style={{
insetInlineStart:
headerWidth + starts[landing.to > landing.from ? landing.to + 1 : landing.to]! - 1,
height: viewport,
}}
className="pointer-events-none absolute top-0 z-20 w-0.5 bg-focus-ring forced-colors:bg-[Highlight]"
/>
)}
</div>
<div
role="rowgroup"
className="relative"
style={{ height: scrollSize(size), width: headerWidth + (starts[columnCount] ?? 0) }}
>
{drawn.map((index) => {
const isActiveRow = active?.row === index;
return (
<GridRow
key={index}
gridId={gridId}
index={index}
row={rows[recordAt(index)] ?? EMPTY_ROW}
columns={columns}
widths={widths}
top={index * height + shift}
height={height}
spans={rowSpans(selection, index)}
activeColumn={isActiveRow ? active.column : null}
mode={isActiveRow && editing !== null ? editing.mode : null}
problem={isActiveRow && editing !== null ? (problem?.text ?? null) : null}
refused={isActiveRow && editing !== null && problem?.refused === true}
readOnly={readOnly}
lazyEditor={coarse}
messages={messages}
recordKey={index < rowCount ? keyOf(recordAt(index)) : null}
locks={locks}
onEditor={onEditor}
onCellValue={onCellValue}
onEditStart={onEditStart}
header={rowHeaders}
wholeRow={wholeRowSelected(selection, index, columnCount)}
newRow={index >= rowCount ? messages.newRow : null}
marked={marks.get(recordAt(index))}
/>
);
})}
{/* What the selection is held by. Under a mouse it is clipped to the cells, so that a handle at
their edge adds nothing to scroll to. Under a finger it reaches past them by half a handle,
over the headers: the whole of a handle can be pressed wherever its corner is. */}
<div
ref={handles}
aria-hidden
style={{ inset: -reachPast }}
className={cn(
"pointer-events-none invisible absolute group-focus-within/grid:visible group-data-open/grid:visible",
coarse ? "z-40" : "z-10 [overflow-clip-margin:8px] [overflow:clip]",
)}
>
{fillPreview !== null && (
<div
data-fill-preview=""
style={{
top: fillPreview.bounds.top * height + shift + reachPast,
insetInlineStart: headerWidth + starts[fillPreview.bounds.left]! + reachPast,
width: starts[fillPreview.bounds.right + 1]! - starts[fillPreview.bounds.left]!,
height: (fillPreview.bounds.bottom - fillPreview.bounds.top + 1) * height,
}}
className="absolute border-2 border-dashed border-focus-ring"
/>
)}
{/* Under a finger, a handle at each end of the selection: dragged, it redraws the selection from the other. */}
{coarse &&
idle &&
block !== null &&
(
[
["start", block.top, block.left],
["end", block.bottom + 1, block.right + 1],
] as const
).map(
([corner, row, column]) =>
// Over the headers, so that the whole of it can be pressed where it is at their edge.
(holding || cornerShown(row, column)) && (
<div
key={corner}
data-handle={corner}
// Held to be dragged: a finger that rests on it has not asked for the menu.
data-drag-handle=""
onPointerDown={(event) => beginCorner(event, corner)}
style={{
top: row * height + shift,
insetInlineStart: headerWidth + starts[column]!,
width: TOUCH_TARGET,
height: TOUCH_TARGET,
}}
className="pointer-events-auto absolute grid touch-none place-items-center"
>
<span className="size-3.5 rounded-full border-2 border-surface bg-focus-ring shadow-sm forced-colors:bg-[Highlight]" />
</div>
),
)}
{/* Under a mouse, one small handle at the selection's end: dragged, it fills the cells it passes over. */}
{!coarse && !readOnly && idle && block !== null && inView(block.bottom + 1) && (
<div
data-fill-handle=""
data-drag-handle=""
onPointerDown={beginFill}
style={{
top: (block.bottom + 1) * height + shift + reachPast - 5,
insetInlineStart: headerWidth + starts[block.right + 1]! + reachPast - 5,
}}
className="pointer-events-auto absolute size-2.5 cursor-crosshair touch-none border border-surface bg-focus-ring forced-colors:bg-[Highlight]"
/>
)}
</div>
</div>
</ContextMenuTrigger>
<MenuContent
ref={menu}
aria-label={about === undefined ? messages.menu : messages.columnMenu(about.label)}
>
{about === undefined ? (
<>
<MenuItem disabled={!canChange} onSelect={() => copyFromMenu(true)} shortcut="Mod+X">
{messages.cut}
</MenuItem>
<MenuItem disabled={block === null} onSelect={() => copyFromMenu(false)} shortcut="Mod+C">
{messages.copy}
</MenuItem>
<MenuItem disabled={!canChange} onSelect={pasteFromMenu} shortcut="Mod+V">
{messages.paste}
</MenuItem>
<MenuItem disabled={!canChange} onSelect={() => clear(selection.map(boundsOf))} shortcut="Delete">
{messages.clear}
</MenuItem>
<MenuSeparator />
<MenuItem
disabled={!canChange || block === null || fillFromFirst(block, "rows") === null}
onSelect={() => fillFrom("rows")}
shortcut="Mod+D"
>
{messages.fillDown}
</MenuItem>
<MenuItem
disabled={!canChange || block === null || fillFromFirst(block, "columns") === null}
onSelect={() => fillFrom("columns")}
shortcut="Mod+R"
>
{messages.fillAcross}
</MenuItem>
{/* A series continued as a drag of the handle continues it: where there is no handle, under a finger, as well. */}
<MenuItem
disabled={series === null}
onSelect={() => {
if (series !== null) fillCells(series.source, series.target, false);
}}
>
{messages.fillSeries}
</MenuItem>
<MenuSeparator />
<MenuItem disabled={!canInsert} onSelect={insertRows}>
{messages.insertRows(rowsAbove)}
</MenuItem>
<MenuItem disabled={!canRemove || rowsIn === 0} onSelect={() => void removeRows(true)}>
{messages.removeRows(Math.max(rowsIn, 1))}
</MenuItem>
{/* Where locks mean something: on computed cells always, and on others where the owner works with them. */}
{locking !== null && <MenuSeparator />}
{locking?.hold === true && (
<MenuItem onSelect={() => lockCells(locking, "held")}>{messages.hold}</MenuItem>
)}
{locking?.freeze === true && (
<MenuItem onSelect={() => lockCells(locking, "frozen")}>{messages.freeze}</MenuItem>
)}
{locking?.unlock === true && (
<MenuItem onSelect={() => unlockCells(locking)}>
{locking.unfreeze ? messages.unfreeze : messages.unlock}
</MenuItem>
)}
</>
) : (
<>
<MenuCheckboxItem
checked={sort?.key === about.key && sort.direction === "ascending"}
onCheckedChange={() => sortBy(aboutAt, "ascending")}
>
{messages.sortAscending}
</MenuCheckboxItem>
<MenuCheckboxItem
checked={sort?.key === about.key && sort.direction === "descending"}
onCheckedChange={() => sortBy(aboutAt, "descending")}
>
{messages.sortDescending}
</MenuCheckboxItem>
<MenuSeparator />
<MenuItem onSelect={() => resize(aboutAt, fitWidth(aboutAt))}>{messages.fit}</MenuItem>
{columnCount > 1 && <MenuSeparator />}
{before !== undefined && (
<MenuItem onSelect={() => void moveColumn(aboutAt, aboutAt - 1)}>
{messages.moveBefore(before.label)}
</MenuItem>
)}
{after !== undefined && (
<MenuItem onSelect={() => void moveColumn(aboutAt, aboutAt + 1)}>
{messages.moveAfter(after.label)}
</MenuItem>
)}
</>
)}
</MenuContent>
{/* What the grid has just done, for those who cannot see it done. */}
<div {...announcementProps} />
</ContextMenu>
);
}
/** Whether some range of a selection takes in the whole of a row. */
function wholeRowSelected(selection: GridSelection, row: number, columnCount: number): boolean {
return selection.some((range) => {
const { top, left, bottom, right } = boundsOf(range);
return row >= top && row <= bottom && left === 0 && right === columnCount - 1;
});
}
/** The far corner of a selection's active range, as the arguments `isCellSelected` takes. */
function farCorner(selection: GridSelection): [row: number, column: number] {
const cell = focusCell(selection)!;
return [cell.row, cell.column];
}
const ESCAPES: Record<string, string> = { "&": "&", "<": "<", ">": ">" };
/** A block of cells as an HTML table: what a spreadsheet reads in preference to plain text. */
function tableOf(block: ReadonlyArray<readonly string[]>): string {
const rows = block.map(
(line) =>
`<tr>${line.map((text) => `<td>${text.replace(/[&<>]/g, (char) => ESCAPES[char]!).replaceAll("\n", "<br>")}</td>`).join("")}</tr>`,
);
return `<table>${rows.join("")}</table>`;
}