← ui

table

ui

a table to read and edit: thousands of rows, sticky heads, a spreadsheet's keys

npx shadcn add @ag/table
Already installed? Preview an update
npx shadcn add @ag/table --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • Changed: the choices under a cell being edited (data-slot="table-choices", a text column's few repeated values) are a frame that stays whole, and the options scroll in an inner role="listbox" element that fades its cut edges. The frame keeps the slot; the listbox role, id and label moved to the inner element.
Updated 2026-10-12 · release notes

also called data grid

shelf data & status

uses scrims, selection, key-router, menu, scroll-area, scroll-thumb, shortcut

the demo above, verbatim

table.demo.tsx
"use client"

import "@/registry/base-nova/ui/edge-fade"
import { ArrowUp, Pause, Play, Trash2 } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import type { MenuEntry } from "@/registry/base-nova/ui/menu"
import {
  CsvTable,
  Table,
  type TableColumn,
  type TableDensity,
} from "@/registry/base-nova/ui/table"

// #region csv
// A file's text in, the text out on every edit: the table is the editor, the CSV
// stays the truth. Kinds are read: cost is a number, paid a yes/no, on a date.
const BUDGET = `item,category,cost,paid,on
fuel,travel,84.20,yes,2026-08-14
tolls,travel,23.50,yes,2026-08-14
cabin,stay,310.00,yes,2026-08-15
groceries,food,96.75,no,2026-08-15
ferry,travel,58.00,no,2026-08-17
sd cards,gear,42.99,yes,2026-08-12
`
// #endregion

function Csv() {
  const [text, setText] = React.useState(BUDGET)
  return (
    <div className="grid w-full gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">
      <CsvTable
        label="budget.csv"
        text={BUDGET}
        onChange={setText}
        className="h-80 rounded-lg border border-foreground/8"
      />
      {/* The surface a frame round the scroller, whose cut edges fade. */}
      <div className="flex h-80 rounded-lg bg-foreground/4">
        <pre className="ag-edge-fade-xy min-h-0 flex-1 p-3 font-mono text-[11px] leading-5 text-foreground/70">
          {text}
        </pre>
      </div>
    </div>
  )
}

// #region rows
// Rows of your own: the text sorts and copies, `render` draws what text cannot, a
// tint marks priority, and the verbs read what they act on.
interface Download {
  id: string
  title: string
  state: "downloading" | "paused" | "queued"
  progress: number
  speed: number
  size: number
  priority: "high" | "normal" | "low"
}

const DOWNLOADS: Download[] = [
  {
    id: "d1",
    title: "the bay, wide",
    state: "downloading",
    progress: 0.62,
    speed: 41.2,
    size: 4.1,
    priority: "high",
  },
  {
    id: "d2",
    title: "hands on the map",
    state: "downloading",
    progress: 0.18,
    speed: 12.5,
    size: 1.6,
    priority: "normal",
  },
  {
    id: "d3",
    title: "light through the trees",
    state: "paused",
    progress: 0.4,
    speed: 0,
    size: 2.3,
    priority: "normal",
  },
  {
    id: "d4",
    title: "the boat leaving",
    state: "queued",
    progress: 0,
    speed: 0,
    size: 3.8,
    priority: "low",
  },
  {
    id: "d5",
    title: "the pass at dusk",
    state: "queued",
    progress: 0,
    speed: 0,
    size: 5.2,
    priority: "high",
  },
]

const COLUMNS: TableColumn<Download>[] = [
  { id: "title", label: "title", text: (d) => d.title },
  // A few values repeated: read as choices, drawn as badges.
  { id: "state", label: "state", text: (d) => d.state },
  {
    id: "progress",
    label: "progress",
    text: (d) => `${Math.round(d.progress * 100)}%`,
    width: "9rem",
    render: (d) => (
      <span className="flex w-full items-center gap-2">
        <span className="h-1 flex-1 overflow-hidden rounded-full bg-foreground/10">
          <span
            className="block h-full w-(--done) rounded-full bg-foreground/60"
            style={{ "--done": `${d.progress * 100}%` } as React.CSSProperties}
          />
        </span>
        <span className="w-8 text-right text-[11px] text-foreground/50 tabular-nums">
          {Math.round(d.progress * 100)}%
        </span>
      </span>
    ),
  },
  {
    id: "speed",
    label: "MB/s",
    // Unknowable while not downloading: an empty cell, never a fake zero.
    text: (d) => (d.state === "downloading" ? d.speed.toFixed(1) : ""),
  },
  { id: "size", label: "GB", text: (d) => d.size.toFixed(1) },
]
// #endregion

function Rows() {
  const [rows, setRows] = React.useState(DOWNLOADS)
  const patch = (ids: readonly string[], f: (d: Download) => Download) =>
    setRows((all) => all.map((d) => (ids.includes(d.id) ? f(d) : d)))
  const actions = (ids: readonly string[]): MenuEntry[] => {
    const chosen = rows.filter((d) => ids.includes(d.id))
    const allPaused = chosen.every((d) => d.state === "paused")
    return [
      allPaused
        ? {
            icon: <Play />,
            label: "resume",
            onSelect: () => patch(ids, (d) => ({ ...d, state: "downloading" })),
          }
        : {
            icon: <Pause />,
            label: "pause",
            onSelect: () => patch(ids, (d) => ({ ...d, state: "paused" })),
          },
      {
        icon: <ArrowUp />,
        label: "high priority",
        disabled: chosen.every((d) => d.priority === "high"),
        onSelect: () => patch(ids, (d) => ({ ...d, priority: "high" })),
      },
      "separator",
      {
        icon: <Trash2 />,
        label: "remove",
        destructive: true,
        onSelect: () =>
          setRows((all) => all.filter((d) => !ids.includes(d.id))),
      },
    ]
  }
  return (
    <Table
      label="downloads"
      rows={rows}
      rowId={(d) => d.id}
      columns={COLUMNS}
      actions={actions}
      noun="download"
      tint={(d) =>
        d.priority === "high"
          ? "color-mix(in oklab, var(--color-foreground) 5%, transparent)"
          : undefined
      }
      className="h-52 w-full rounded-lg border border-foreground/8"
    />
  )
}

// #region saved
// Every edit is saved somewhere slow and fallible: `onEdit` returns the save's
// promise, so the cells show the new text at once and roll back if it is refused.
// Status is a choice (read from its values), active a switch.
interface Account {
  id: string
  name: string
  status: string
  plan: string
  active: string
}

const ACCOUNTS: Account[] = [
  {
    id: "a1",
    name: "north studio",
    status: "live",
    plan: "team",
    active: "yes",
  },
  {
    id: "a2",
    name: "harbour films",
    status: "trial",
    plan: "solo",
    active: "yes",
  },
  { id: "a3", name: "pass & co", status: "paused", plan: "team", active: "no" },
  {
    id: "a4",
    name: "ridge sound",
    status: "live",
    plan: "solo",
    active: "yes",
  },
  {
    id: "a5",
    name: "market lane",
    status: "live",
    plan: "team",
    active: "yes",
  },
  { id: "a6", name: "cabin edit", status: "trial", plan: "solo", active: "no" },
]

const ACCOUNT_COLUMNS: TableColumn<Account>[] = [
  { id: "name", label: "name", text: (a) => a.name },
  { id: "status", label: "status", text: (a) => a.status },
  // Declared: a plan the data has not used yet is still offered.
  {
    id: "plan",
    label: "plan",
    text: (a) => a.plan,
    options: ["solo", "team", "studio"],
  },
  { id: "active", label: "active", text: (a) => a.active },
]

/** A server: answers after a moment, and refuses when told to. */
const save = (refuse: boolean) =>
  new Promise<void>((resolve, reject) =>
    setTimeout(
      () => (refuse ? reject(new Error("the server said no")) : resolve()),
      900,
    ),
  )

function Saved() {
  const [rows, setRows] = React.useState(ACCOUNTS)
  const [refuse, setRefuse] = React.useState(false)
  return (
    <div className="w-full space-y-3">
      <label className="flex w-fit items-center gap-2 text-[13px] text-foreground/70">
        <input
          type="checkbox"
          checked={refuse}
          onChange={(e) => setRefuse(e.target.checked)}
        />
        the server refuses
      </label>
      <Table
        label="accounts"
        rows={rows}
        rowId={(a) => a.id}
        columns={ACCOUNT_COLUMNS}
        noun="account"
        onEdit={async (edits) => {
          await save(refuse)
          setRows((all) =>
            all.map((a) => ({
              ...a,
              ...Object.fromEntries(
                edits
                  .filter((e) => e.row === a.id)
                  .map((e) => [e.column, e.text]),
              ),
            })),
          )
        }}
        className="h-64 w-full rounded-lg border border-foreground/8"
      />
    </div>
  )
}
// #endregion

// #region staged
// A config whose edits wait: change what you like, see what diverges, then apply it
// as one batch (or discard it).
const SETTINGS = [
  { id: "s1", key: "workers", value: "4", env: "production" },
  { id: "s2", key: "timeout", value: "30s", env: "production" },
  { id: "s3", key: "retries", value: "3", env: "production" },
  { id: "s4", key: "workers", value: "1", env: "staging" },
  { id: "s5", key: "timeout", value: "120s", env: "staging" },
  { id: "s6", key: "retries", value: "0", env: "staging" },
]
type Setting = (typeof SETTINGS)[number]
const SETTING_COLUMNS: TableColumn<Setting>[] = [
  // Repeats, but names a thing rather than a state: free text, not choices.
  {
    id: "key",
    label: "key",
    text: (s) => s.key,
    editable: false,
    options: [],
  },
  { id: "env", label: "env", text: (s) => s.env, editable: false },
  { id: "value", label: "value", text: (s) => s.value },
]

function Staged() {
  const [rows, setRows] = React.useState(SETTINGS)
  return (
    <Table
      label="settings"
      rows={rows}
      rowId={(s) => s.id}
      columns={SETTING_COLUMNS}
      staged
      onEdit={async (edits) => {
        await save(false)
        setRows((all) =>
          all.map((s) => {
            const e = edits.find((e) => e.row === s.id)
            return e ? { ...s, value: e.text } : s
          }),
        )
      }}
      className="h-64 w-full rounded-lg border border-foreground/8"
    />
  )
}
// #endregion

const DENSITIES: TableDensity[] = ["compact", "comfortable", "spacious"]

function Many() {
  const [density, setDensity] = React.useState<TableDensity>("compact")
  return (
    <div className="w-full space-y-3">
      <fieldset className="flex w-fit gap-0.5 rounded-lg bg-foreground/5 p-0.5 text-[12px]">
        <legend className="sr-only">density</legend>
        {DENSITIES.map((d) => (
          <label
            key={d}
            className="cursor-pointer rounded-md px-2.5 py-1 text-foreground/55 has-checked:bg-background has-checked:text-foreground has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-ring"
          >
            <input
              type="radio"
              name="density"
              value={d}
              checked={density === d}
              onChange={() => setDensity(d)}
              className="sr-only"
            />
            {d}
          </label>
        ))}
      </fieldset>
      <Table
        label="shots"
        rows={MANY}
        rowId={(s) => s.id}
        columns={MANY_COLUMNS}
        density={density}
        className="h-96 w-full rounded-lg border border-foreground/8"
      />
    </div>
  )
}

// #region many
// Ten thousand rows cost what the view shows: only those are mounted.
const PLACES = ["bay", "pass", "harbour", "ridge", "market", "ferry", "cabin"]
const MANY = Array.from({ length: 10_000 }, (_, i) => ({
  id: `m${i}`,
  shot: `${PLACES[i % PLACES.length]} ${Math.floor(i / PLACES.length) + 1}`,
  seconds: ((i * 7919) % 600) / 10,
  kept: (i * 31) % 3 === 0 ? "yes" : "no",
  on: `2026-${String((i % 12) + 1).padStart(2, "0")}-${String((i % 28) + 1).padStart(2, "0")}`,
}))
type Shot = (typeof MANY)[number]
const MANY_COLUMNS: TableColumn<Shot>[] = [
  { id: "shot", label: "shot", text: (s) => s.shot },
  { id: "seconds", label: "seconds", text: (s) => s.seconds.toFixed(1) },
  { id: "kept", label: "kept", text: (s) => s.kept },
  { id: "on", label: "on", text: (s) => s.on },
]
// #endregion

export default function TableDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="csv"
        title="a CSV, edited in place"
        description="Double-click, Enter or type to edit; Enter goes down, Tab right, Esc gives up. A check flips on a press, Space or Enter. ⇧ with a move or a click, or a drag, takes a block of cells: ⌘C copies it, ⌘V pastes into it (one value fills it), Backspace clears it, ⌘D and ⌘R fill it down and right. ⌘ with an arrow jumps to the edge of the data, ⌘G goes to an address (B3, 4, C, A2:B5), ⌘Z undoes. A header sorts, its edge resizes, its right click inserts, moves or deletes columns, a double-click renames it. Right-click rows to insert, duplicate or delete. The text on the right is what would be saved."
        with="csv"
      >
        <Csv />
      </Sample>
      <Sample
        name="rows"
        glance="wide"
        title="rows of your own"
        description="Click a row's number to select it, ⌘ adds, ⇧ a range; from the keyboard ⇧Space takes the rows of the cells you have, Space the cursor's. The verbs rise in a bar, the same ones a right click offers."
        with="rows"
      >
        <Rows />
      </Sample>
      <Sample
        name="saved"
        title="saved, and maybe refused"
        description="Each edit shows at once, faded until the save answers. Tick the box and edit again: the save is refused, the cells go back and the reason shows at the foot. Status and plan are choices (Enter or type to pick, or write a new one), active flips on Space."
        with="saved"
      >
        <Saved />
      </Sample>
      <Sample
        name="staged"
        title="staged, applied as one"
        description="Edit values: each changed cell is marked and remembers what it was (hover it), ⌘Z steps back, and the bar applies them all in one call (⌘S) or discards them."
        with="staged"
      >
        <Staged />
      </Sample>
      <Sample name="many" title="ten thousand rows, any density" with="many">
        <Many />
      </Sample>
    </div>
  )
}

the source, every file an install writes

table.tsx
"use client"

// A TABLE, to read and to edit: thousands of rows windowed, the header and the first
// column held while the rest scrolls under them, columns sorted and resized, a cell
// cursor and a block of cells walked by the keyboard as a spreadsheet's, cells edited
// in place, rows selected the one way every set is (selection), and the rows' verbs on
// a right click and on a bar.
//
// THE CONSUMER OWNS THE DATA. A column says how to read a row's text (what sorts, what
// its kind is read from, what an edit starts from, what is copied) and, when the text
// alone is not the face, how to draw it (`render`: a badge, a progress bar). Edits are
// handed back as text, every cell one gesture changed in one call (`onEdit`), so a
// paste or a fill is one change and one undo; the table never writes. `CsvTable` below
// is one consumer: a CSV's text in, the text written back out on every change.
//
// KINDS ARE READ (table-data): numbers right-aligned in tabular figures, dates in
// tabular figures, a yes/no column as a check that a press flips (writing the column's
// own words back), everything else text. Sorting follows the kind; empty cells last. A
// text column that repeats a few values chooses among them (`choicesOf`, or the
// column's `options`): its cells wear the value as a badge and an edit offers the
// values as a list that narrows as you type, a new value still written as typed.
//
// AN EDIT IS ASKED FOR, AND MAY BE REFUSED. When `onEdit` answers with a promise the
// cells show the new text at once, dimmed, until it settles: kept when it resolves (the
// consumer's rows hold it by then), rolled back when it rejects, the cells marked and
// the reason said at the foot. With `staged`, edits are not asked for at all until
// applied: each changed cell carries a mark and what it was, ⌘Z steps back through
// them, and a bar at the foot applies them as one call (⌘S) or discards them. What is
// shown, sorted, found and copied is always the text as it will be.
//
// ONE GRID. Every row, the header included, is a CSS grid on one template, so a column
// is one width everywhere and a resize moves it everywhere at once. The gutter (row
// numbers, the rows' own selection handle) and the first column are sticky to the
// left, the header to the top. The table sits on one ground (`--ag-table-ground`, the
// page colour unless set) and the sticky parts are that ground, carrying their row's
// tint as a gradient, so nothing reads as a band of another colour.
//
// TWO SELECTIONS, a spreadsheet's. CELLS: the cursor, and a block from an anchor to
// it. A click puts the cursor on a cell; ⇧-click, ⇧ with any move, or a drag across
// cells grows the block; copy, paste, clear and fill act on it. ROWS (selection, the
// kernel the gallery and the rail share): a click on a row's number selects it alone,
// ⌘ adds, ⇧ a range in the shown order; ⇧Space takes the block's rows, Space the
// cursor's, ⌘A all; the rows' verbs act on them. A plain move or click leaves both.
//
// THE KEYS. Arrows, Tab, Home/End (row start and end; ⌘ the first and last cell),
// PageUp/Down, ⌘ with an arrow to the edge of the data (Excel's ⌃-arrow), all of them
// growing the block with ⇧. Enter, F2, a double-click or typing edits (Enter commits and
// goes down, Tab right, Esc gives up); Space or Enter flips a check. Backspace clears
// the block, ⌘D fills it down and ⌘R right from its first row or column, ⌘C copies it
// (or the selected rows) as tab-separated text and ⌘V pastes a block of text at it (one
// value fills it), ⌘Z and ⇧⌘Z undo and redo (staged edits first, then the consumer's
// history when it keeps one), ⌘S applies staged edits. ⌘G goes
// to an address: the corner box shows the cursor's ("C3", the block's "B2:D5") and
// takes one typed, a cell, a row ("9"), a column ("B") or a block, as sheets' `:B9`.
// ⌘F finds: a bar over the top right corner, every cell holding the words washed, the
// cursor on the first at once, Enter and ⇧Enter walking them, Esc closing. Esc lets go
// of the block, then the rows. A header click sorts (ascending, descending, as given);
// a header dragged sideways moves its column (a line shows where it lands); its edge
// resizes the column (a double-click there fits it); its right click opens the
// column's verbs. The grid is one tab stop: the cursor is `aria-activedescendant`, and
// its row is always mounted.

import {
  ArrowDown,
  ArrowDownWideNarrow,
  ArrowLeft,
  ArrowRight,
  ArrowUp,
  ArrowUpNarrowWide,
  Check,
  Copy,
  Plus,
  Rows3,
  Trash2,
  Undo2,
  X,
} from "lucide-react"
import * as React from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import {
  actsOn,
  all,
  inOrder,
  NONE,
  only,
  range,
  type Selection,
  toggle,
} from "@/registry/base-nova/lib/selection"
import {
  type ColumnKind,
  type Csv,
  choicesOf,
  columnName,
  flipped,
  kindOf,
  parseAddress,
  parseCsv,
  type SortDirection,
  sorted,
  toCsv,
  truthOf,
  truthyOf,
} from "@/registry/base-nova/lib/table-data"
import { ownedHere, typingIn } from "@/registry/base-nova/ui/key-router"
import { Menu, type MenuEntry, verbFor } from "@/registry/base-nova/ui/menu"
import { ScrollArea } from "@/registry/base-nova/ui/scroll-area"
import {
  NO_SCROLLBAR,
  ScrollThumbs,
} from "@/registry/base-nova/ui/scroll-thumb"
import {
  BAR,
  BAR_RULE,
  BarVerb,
  SelectionBar,
} from "@/registry/base-nova/ui/selection-bar"
import { usePlatform } from "@/registry/base-nova/ui/shortcut"

export interface TableColumn<R> {
  id: string
  label: string
  /** The cell's text: what sorts, what the column's kind is read from, what an edit
   *  starts from and what is copied. */
  text: (row: R) => string
  /** The cell's face when its text is not enough. */
  render?: (row: R) => React.ReactNode
  /** Read from the column's texts when not given. */
  kind?: ColumnKind
  /** A CSS length; the content's width in characters when not given. */
  width?: string
  /** Defaults to whether the table takes edits. */
  editable?: boolean
  /** The values a cell may take (a status, a category): drawn as a badge, offered as
   *  a list when edited, a typed value still written as typed. Read from the column
   *  when its texts repeat a few values; `[]` reads it as free text. */
  options?: readonly string[]
}

/** One cell's new text. */
export interface CellEdit {
  row: string
  column: string
  text: string
}

export type TableDensity = "compact" | "comfortable" | "spacious"

export interface TableProps<R> {
  rows: readonly R[]
  rowId: (row: R) => string
  columns: readonly TableColumn<R>[]
  /** The grid's accessible name. */
  label: string
  /** The cells one gesture changed (an edit, a paste, a fill, a clear), committed
   *  together. The consumer applies them; the table only asks. A promise makes the
   *  edit optimistic: the new text shows until it settles, and a rejection rolls the
   *  cells back and says why (its message). */
  // biome-ignore lint/suspicious/noConfusingVoidType: a callback that applies at once returns nothing (a setState), one that saves returns its promise
  onEdit?: (edits: readonly CellEdit[]) => void | Promise<unknown>
  /** Edits wait instead of being asked for: marked on their cells, undone with ⌘Z,
   *  and handed to `onEdit` as one batch when applied (the bar, or ⌘S). */
  staged?: boolean
  /** ⌘Z and ⇧⌘Z, for a consumer that keeps history. */
  onUndo?: () => void
  onRedo?: () => void
  /** A column's new name, from a double-click on its header. */
  onRename?: (columnId: string, label: string) => void
  /** A column moved, by its header dragged or from its menu: to where in the column
   *  order. Given, the order is the consumer's (a file's columns are its data);
   *  without it the table keeps the order the hands made, as a view. */
  onMoveColumn?: (columnId: string, to: number) => void
  /** A row added at the end, from the button under the last row. */
  onAddRow?: () => void
  /** A column added at the end, from the + after the last header. */
  onAddColumn?: () => void
  /** Controlled selection of rows; the table keeps its own when not given. */
  selection?: Selection
  onSelectionChange?: (selection: Selection) => void
  /** The verbs for rows: on a right click for the rows it acts on, and on the
   *  selection bar while any is selected. */
  actions?: (ids: readonly string[]) => readonly MenuEntry[]
  /** More verbs for a column, after sorting and moving. */
  columnActions?: (columnId: string) => readonly MenuEntry[]
  /** A row's tint, a CSS colour (a priority, a state); selection wins over it. */
  tint?: (row: R) => string | undefined
  density?: TableDensity
  /** What one row is called on the selection bar ("row"). */
  noun?: string
  /** false draws a picture of the table: no focus, no hands (a file's face). */
  interactive?: boolean
  className?: string
}

/** A row's height per density, px, on the 8px rhythm. */
const ROW_PX: Record<TableDensity, number> = {
  compact: 24,
  comfortable: 32,
  spacious: 40,
}
/** Rows mounted beyond each edge of the view. */
const OVERSCAN = 8
/** How many rows a column's width is read from, and its bounds in characters. */
const WIDTH_SAMPLE = 400
const WIDTH_CH = { min: 3, max: 40 }
/** The narrowest a column can be dragged to, px. */
const MIN_PX = 40
/** The gutter holds the row numbers and, on top, the address box: at least this many
 *  characters ("AB123"). */
const ADDRESS_CH = 5
const SELECTED_TINT =
  "color-mix(in oklab, var(--color-foreground) 9%, transparent)"

const HEAD =
  "relative flex min-w-0 items-center gap-1.5 border-b border-foreground/10 px-2.5 text-[12px] font-medium text-foreground/55"
const CELL =
  "relative flex min-w-0 items-center border-b border-foreground/6 px-2.5"
/** The one surface the table sits on: the page colour, or the surface around it
 *  (`[--ag-table-ground:…]` on the table). Sticky parts are filled with it so the rows
 *  pass under them. */
const GROUND = "bg-[var(--ag-table-ground,var(--color-background))]"
/** A sticky cell is opaque, and wears its row's tint over the ground. */
const STICKY = `sticky z-10 ${GROUND} bg-linear-to-r from-(--ag-table-tint) to-(--ag-table-tint)`
/** The cell cursor: where the keys act, a spreadsheet's current cell, drawn only
 *  while the table has focus (a table the hands have left shows no box). */
const CURSOR =
  "group-focus/table:data-[cursor]:rounded-[4px] group-focus/table:data-[cursor]:outline-[1.5px] group-focus/table:data-[cursor]:-outline-offset-2 group-focus/table:data-[cursor]:outline-foreground/55"
/** A cell inside the block: washed, over whatever the row wears. */
const BLOCK =
  "data-[block]:after:pointer-events-none data-[block]:after:absolute data-[block]:after:inset-0 data-[block]:after:bg-foreground/8"

/** A cell holding what find looks for: a wash inset from its edges, under the cursor
 *  that walks the matches. */
const MATCH =
  "data-[match]:before:pointer-events-none data-[match]:before:absolute data-[match]:before:inset-1 data-[match]:before:rounded-[4px] data-[match]:before:bg-foreground/12"

/** A cell whose text the rows do not hold yet: pending, its content faded until the
 *  consumer answers; refused, a line of the warning tone along its foot. */
const HELD =
  "data-[pending]:[&>*]:opacity-50 data-[refused]:shadow-[inset_0_-2px_0_0_var(--color-tone-bad)]"
/** An open editor, over its cell. */
const EDITOR =
  "absolute inset-0 z-10 min-w-0 bg-background px-2.5 text-foreground outline-2 -outline-offset-2 outline-ring select-text"

const cellId = (scope: string, row: string, column: string) =>
  `${scope}-${row}-${column}`
/** A cell's key in the maps of edits not yet in the rows. */
const cellKey = (row: string, column: string) => `${row}\u0000${column}`

/** The width a column's content asks for, in characters. */
function contentWidth<R>(
  column: TableColumn<R>,
  rows: readonly R[],
  kind: ColumnKind,
) {
  let n = column.label.length
  if (kind !== "boolean")
    for (let i = 0; i < Math.min(rows.length, WIDTH_SAMPLE); i++)
      n = Math.max(n, column.text(rows[i] as R).length)
  return Math.min(WIDTH_CH.max, Math.max(WIDTH_CH.min, n))
}

/** Tab-separated text as rows of cells (a trailing line break is not a row). */
const matrixOf = (text: string) =>
  text
    .replace(/\r?\n$/, "")
    .split(/\r?\n/)
    .map((line) => line.split("\t"))

type At = { row: number; column: number }

/** The grid's own parts, whose keys it answers itself (its cursor moves over them). */
const GRID_ROLES = ["row", "gridcell", "rowheader", "columnheader"]

export function Table<R>({
  rows,
  rowId,
  columns: given,
  label,
  onEdit,
  staged = false,
  onUndo,
  onRedo,
  onRename,
  onMoveColumn,
  onAddRow,
  onAddColumn,
  selection: controlled,
  onSelectionChange,
  actions,
  columnActions,
  tint,
  density = "comfortable",
  noun = "row",
  interactive = true,
  className,
}: TableProps<R>) {
  const scope = React.useId().replace(/:/g, "")
  const scroller = React.useRef<HTMLDivElement>(null)
  const grid = React.useRef<HTMLTableElement>(null)
  // An edit commits once: Enter or Tab commit and then hand focus back to the grid,
  // whose blur would commit the same edit again.
  const settled = React.useRef(false)
  // A press on a cell held and moved across others grows the block.
  const dragging = React.useRef(false)
  const [own, setOwn] = React.useState<Selection>(NONE)
  const selection = controlled ?? own
  const select = onSelectionChange ?? setOwn
  const [sort, setSort] = React.useState<{
    column: string
    direction: SortDirection
  } | null>(null)
  const [widths, setWidths] = React.useState<Record<string, number>>({})
  const [cursor, setCursor] = React.useState<{
    row: string
    column: string
  } | null>(null)
  // The block's other corner; none: the cursor's cell alone.
  const [anchor, setAnchor] = React.useState<{
    row: string
    column: string
  } | null>(null)
  const [editing, setEditing] = React.useState<{
    row: string
    column: string
    draft: string
    /** Opened by typing: a list of choices narrows to the draft at once. */
    typed: boolean
  } | null>(null)
  // Edits the rows do not hold yet. Staged: gestures waiting to be applied (and the
  // ones ⌘Z stepped back from). Pending: handed to a consumer whose promise has not
  // settled, each cell tagged with the call that last wrote it so an older call
  // settling never clears a newer one's text. Refused: the cells of a call that was
  // rejected, and its reason.
  const [stage, setStage] = React.useState<{
    done: readonly (readonly CellEdit[])[]
    undone: readonly (readonly CellEdit[])[]
  }>({ done: [], undone: [] })
  const [pending, setPending] = React.useState<
    ReadonlyMap<string, { text: string; call: number }>
  >(new Map())
  const calls = React.useRef(0)
  const [refused, setRefused] = React.useState<{
    cells: ReadonlySet<string>
    reason: string
  } | null>(null)
  const [naming, setNaming] = React.useState<string | null>(null)
  const [going, setGoing] = React.useState(false)
  const [menu, setMenu] = React.useState<{
    entries: readonly MenuEntry[]
    at: Element | { x: number; y: number }
  } | null>(null)
  const [view, setView] = React.useState({ top: 0, height: 0, width: 0 })
  // A header being dragged: from which column, and before which one it would land.
  const [drop, setDrop] = React.useState<{ from: number; to: number } | null>(
    null,
  )
  const dragged = React.useRef(false)
  const headRow = React.useRef<HTMLTableRowElement>(null)
  const findInput = React.useRef<HTMLInputElement>(null)
  // Find: what is looked for and which match the cursor is on.
  const [find, setFind] = React.useState<{ query: string; at: number } | null>(
    null,
  )
  const rowPx = ROW_PX[density]
  const platform = usePlatform()

  // ── The columns in the order shown: the consumer's when it keeps one, else the
  // order the hands dragged them into.
  const [arranged, setArranged] = React.useState<readonly string[] | null>(null)
  const shown = React.useMemo(() => {
    if (!arranged || onMoveColumn) return given
    const byColumn = new Map(given.map((c) => [c.id, c]))
    const kept = arranged.flatMap((id) => byColumn.get(id) ?? [])
    return [...kept, ...given.filter((c) => !arranged.includes(c.id))]
  }, [given, arranged, onMoveColumn])
  // The text as it will be: a staged edit over a pending one over the consumer's own.
  // Every reader (the cells, the sort, find, copy, the widths) reads these columns.
  const stagedText = React.useMemo(() => {
    const m = new Map<string, string>()
    for (const gesture of stage.done)
      for (const e of gesture) m.set(cellKey(e.row, e.column), e.text)
    return m
  }, [stage.done])
  const held = React.useMemo(() => {
    const m = new Map<string, string>()
    for (const [k, p] of pending) m.set(k, p.text)
    for (const [k, t] of stagedText) m.set(k, t)
    return m
  }, [pending, stagedText])
  const columns = React.useMemo(
    () =>
      held.size === 0
        ? shown
        : shown.map(
            (c): TableColumn<R> => ({
              ...c,
              text: (r) => held.get(cellKey(rowId(r), c.id)) ?? c.text(r),
            }),
          ),
    [shown, held, rowId],
  )
  const moveColumn = (id: string, to: number) => {
    if (onMoveColumn) return onMoveColumn(id, to)
    const ids = columns.map((c) => c.id).filter((c) => c !== id)
    ids.splice(to, 0, id)
    setArranged(ids)
  }

  React.useEffect(() => {
    const up = () => {
      dragging.current = false
    }
    window.addEventListener("pointerup", up)
    return () => window.removeEventListener("pointerup", up)
  }, [])

  // ── What is shown: rows by id, each column's kind, the order the sort gives.
  const byId = React.useMemo(
    () => new Map(rows.map((r) => [rowId(r), r])),
    [rows, rowId],
  )
  const kinds = React.useMemo(
    () =>
      new Map(
        columns.map((c) => [c.id, c.kind ?? kindOf(rows.map(c.text))] as const),
      ),
    [columns, rows],
  )
  const kindOfColumn = (id: string) => kinds.get(id) as ColumnKind
  // The values a text column chooses among: given, or read from its own texts.
  const choices = React.useMemo(
    () =>
      new Map(
        columns.map((c) => {
          const options =
            c.options ??
            (kinds.get(c.id) === "text" ? choicesOf(rows.map(c.text)) : null)
          return [c.id, options?.length ? options : null] as const
        }),
      ),
    [columns, kinds, rows],
  )
  const columnIndex = React.useMemo(
    () => new Map(columns.map((c, i) => [c.id, i])),
    [columns],
  )
  const order = React.useMemo(() => {
    const ids = rows.map(rowId)
    if (!sort) return ids
    const c = columns.find((c) => c.id === sort.column)
    if (!c) return ids
    return sorted(
      ids,
      (id) => c.text(byId.get(id) as R),
      kinds.get(c.id) as ColumnKind,
      sort.direction,
    )
  }, [rows, rowId, sort, columns, byId, kinds])
  const index = React.useMemo(
    () => new Map(order.map((id, i) => [id, i])),
    [order],
  )
  const chosen = React.useMemo(
    () => inOrder(order, selection),
    [order, selection],
  )
  const editable = (c: TableColumn<R>) => !!onEdit && (c.editable ?? true)
  const textAt = (row: number, column: number) =>
    (columns[column] as TableColumn<R>).text(
      byId.get(order[row] as string) as R,
    )

  // ── Where the cursor and the block are, as places in the shown order.
  const placeOf = (cell: { row: string; column: string } | null): At | null => {
    if (!cell) return null
    const row = index.get(cell.row)
    const column = columnIndex.get(cell.column)
    return row === undefined || column === undefined ? null : { row, column }
  }
  const here = placeOf(cursor) ?? { row: 0, column: 0 }
  const corner = placeOf(anchor) ?? here
  const block = {
    top: Math.min(here.row, corner.row),
    bottom: Math.max(here.row, corner.row),
    left: Math.min(here.column, corner.column),
    right: Math.max(here.column, corner.column),
  }
  const wide = block.top !== block.bottom || block.left !== block.right
  const address = !cursor
    ? ""
    : wide
      ? `${columnName(block.left)}${block.top + 1}:${columnName(block.right)}${block.bottom + 1}`
      : `${columnName(here.column)}${here.row + 1}`

  // ── The template every row shares.
  const gutterCh =
    Math.max(String(Math.max(order.length, 1)).length, ADDRESS_CH) + 2
  const tracks = columns.map((c) =>
    widths[c.id] !== undefined
      ? `${widths[c.id]}px`
      : (c.width ??
        `calc(${contentWidth(c, rows, kindOfColumn(c.id))}ch + 1.25rem)`),
  )
  const template = [
    `${gutterCh}ch`,
    ...tracks,
    ...(onAddColumn && interactive ? ["2rem"] : []),
  ].join(" ")

  // ── The window: what the view shows, plus the cursor's row wherever it is.
  React.useLayoutEffect(() => {
    const el = scroller.current
    if (!el) return
    const measure = () =>
      setView({
        top: el.scrollTop,
        height: el.clientHeight,
        width: el.clientWidth,
      })
    measure()
    const ro = new ResizeObserver(measure)
    ro.observe(el)
    return () => ro.disconnect()
  }, [])
  const first = Math.max(0, Math.floor(view.top / rowPx) - 1 - OVERSCAN)
  const last = Math.min(
    order.length,
    Math.ceil((view.top + view.height) / rowPx) + OVERSCAN,
  )
  const mounted: number[] = []
  for (let i = first; i < last; i++) mounted.push(i)
  const cursorAt = cursor ? index.get(cursor.row) : undefined
  if (cursorAt !== undefined && (cursorAt < first || cursorAt >= last))
    mounted.push(cursorAt)

  // The cursor's cell comes into view when it moves, clear of the sticky header and
  // first column (their sizes are its scroll margins).
  React.useEffect(() => {
    if (!cursor) return
    document
      .getElementById(cellId(scope, cursor.row, cursor.column))
      ?.scrollIntoView({ block: "nearest", inline: "nearest" })
  }, [cursor, scope])

  // ── Moving: the cursor to a place; `grow` keeps the block's other corner where it
  // was (⇧), else the block and the rows are let go.
  const cellAt = (at: At) => ({
    row: order[Math.max(0, Math.min(order.length - 1, at.row))] as string,
    column: (
      columns[
        Math.max(0, Math.min(columns.length - 1, at.column))
      ] as TableColumn<R>
    ).id,
  })
  const moveTo = (at: At, grow = false) => {
    if (order.length === 0 || columns.length === 0) return
    const cell = cellAt(at)
    if (grow) setAnchor((a) => a ?? cursor ?? cell)
    else {
      setAnchor(null)
      if (selection.ids.size > 0 || selection.anchor !== cell.row)
        select({ ids: new Set(), anchor: cell.row, cursor: cell.row })
    }
    setCursor(cell)
  }
  /** Excel's ⌘-arrow: across the run of filled cells to its end, or across the empty
   *  ones to the next filled, or to the table's edge. */
  const edge = (from: At, dRow: number, dColumn: number): At => {
    const inside = (r: number, c: number) =>
      r >= 0 && r < order.length && c >= 0 && c < columns.length
    const filled = (r: number, c: number) => textAt(r, c).trim() !== ""
    let { row: r, column: c } = from
    if (!inside(r + dRow, c + dColumn)) return from
    if (filled(r, c) && filled(r + dRow, c + dColumn)) {
      while (inside(r + dRow, c + dColumn) && filled(r + dRow, c + dColumn)) {
        r += dRow
        c += dColumn
      }
      return { row: r, column: c }
    }
    r += dRow
    c += dColumn
    while (inside(r + dRow, c + dColumn) && !filled(r, c)) {
      r += dRow
      c += dColumn
    }
    return { row: r, column: c }
  }
  const focusGrid = () => grid.current?.focus({ preventScroll: true })

  // ── Changing cells: every gesture is one call, or one staged step.
  /** A cell's text as the consumer's rows hold it, under every held edit. */
  const baseOf = (row: string, column: string) => {
    const r = byId.get(row)
    const c = shown.find((c) => c.id === column)
    return r === undefined || !c ? undefined : c.text(r)
  }
  /** Asks the consumer; a promise holds the cells at their new text until it
   *  settles, and a rejection gives them back and says why. */
  const hand = (edits: readonly CellEdit[]) => {
    setRefused(null)
    const answer = onEdit?.(edits)
    if (!(answer instanceof Promise)) return
    const call = ++calls.current
    setPending((p) => {
      const next = new Map(p)
      for (const e of edits)
        next.set(cellKey(e.row, e.column), { text: e.text, call })
      return next
    })
    const settle = () =>
      setPending((p) => {
        const next = new Map(p)
        for (const e of edits) {
          const k = cellKey(e.row, e.column)
          if (next.get(k)?.call === call) next.delete(k)
        }
        return next
      })
    answer.then(settle, (error: unknown) => {
      settle()
      setRefused({
        cells: new Set(edits.map((e) => cellKey(e.row, e.column))),
        reason: error instanceof Error ? error.message : String(error),
      })
    })
  }
  const change = (edits: CellEdit[]) => {
    const real = edits.filter(
      (e) =>
        e.text !==
        textAt(index.get(e.row) as number, columnIndex.get(e.column) as number),
    )
    if (real.length === 0) return
    if (staged) setStage((s) => ({ done: [...s.done, real], undone: [] }))
    else hand(real)
  }
  // What staging has changed: the cells whose staged text differs from the rows'.
  const changed = [...stagedText].flatMap(([k, text]) => {
    const [row, column] = k.split("\u0000") as [string, string]
    const was = baseOf(row, column)
    return was === undefined || was === text ? [] : [{ row, column, text }]
  })
  const apply = () => {
    setStage({ done: [], undone: [] })
    if (changed.length > 0) hand(changed)
  }
  const discard = () => setStage({ done: [], undone: [] })
  const unstage = (back: boolean) =>
    setStage((s) =>
      back
        ? {
            done: s.done.slice(0, -1),
            undone: [...s.undone, s.done.at(-1) as readonly CellEdit[]],
          }
        : {
            done: [...s.done, s.undone.at(-1) as readonly CellEdit[]],
            undone: s.undone.slice(0, -1),
          },
    )
  const flip = (at: At) => {
    const c = columns[at.column] as TableColumn<R>
    if (!editable(c)) return
    const cell = cellAt(at)
    change([
      {
        ...cell,
        text: flipped(textAt(at.row, at.column), truthyOf(rows.map(c.text))),
      },
    ])
  }
  /** Every editable cell of the block, given its new text. */
  const overBlock = (text: (row: number, column: number) => string) => {
    const edits: CellEdit[] = []
    for (let r = block.top; r <= block.bottom; r++)
      for (let c = block.left; c <= block.right; c++)
        if (editable(columns[c] as TableColumn<R>))
          edits.push({ ...cellAt({ row: r, column: c }), text: text(r, c) })
    return edits
  }
  const startEdit = (at: At, draft?: string) => {
    const c = columns[at.column] as TableColumn<R>
    if (!editable(c)) return
    if (kindOfColumn(c.id) === "boolean") return flip(at)
    settled.current = false
    setEditing({
      ...cellAt(at),
      draft: draft ?? textAt(at.row, at.column),
      typed: draft !== undefined,
    })
  }
  /** An editor closed by a key: Enter commits and goes down (⇧ up), Tab right (⇧
   *  left), Esc gives up; the grid has the keys again. */
  const finish = (at: At, text: string | null, key: string, back: boolean) => {
    if (text === null) giveUp()
    else commit(text)
    focusGrid()
    if (text === null) return
    const d = back ? -1 : 1
    moveTo(
      key === "Tab"
        ? { ...at, column: at.column + d }
        : { ...at, row: at.row + d },
    )
  }
  const commit = (text: string) => {
    if (!editing || settled.current) return
    settled.current = true
    change([{ row: editing.row, column: editing.column, text }])
    setEditing(null)
  }
  const giveUp = () => {
    settled.current = true
    setEditing(null)
  }

  // ── Go to an address typed in the corner box.
  const goTo = (text: string) => {
    const where = parseAddress(text)
    if (!where) return false
    const resolve = (p: { row?: number; column?: number }): At => ({
      row: p.row ?? here.row,
      column: p.column ?? here.column,
    })
    const from = resolve(where.from)
    const to = resolve(where.to)
    if (
      [from, to].some(
        (p) => p.row >= order.length || p.column >= columns.length,
      )
    )
      return false
    moveTo(from)
    if (from.row !== to.row || from.column !== to.column) {
      setAnchor(cellAt(from))
      setCursor(cellAt(to))
    }
    return true
  }

  // ── The keyboard, on the grid. An open editor keeps its own keys, as does a control
  // inside a cell (a row's verb), and a press from a portal (a menu) is never the
  // grid's (key-router's `ownedHere`, the registry's one rule).
  const onKeyDown = (e: React.KeyboardEvent) => {
    if (!e.currentTarget.contains(e.target as Node)) return
    if (ownedHere(e.nativeEvent, e.currentTarget, GRID_ROLES)) return
    const mod = e.metaKey || e.ctrlKey
    const grow = e.shiftKey
    const page = Math.max(1, Math.floor(view.height / rowPx) - 2)
    const run = (f: () => void) => {
      e.preventDefault()
      f()
    }
    // A key a row verb draws runs it first, for what it would act on: the selected
    // rows, else the cursor's. The consumer drew it, so it is the consumer's.
    const acted = chosen.length > 0 ? chosen : cursor ? [cursor.row] : []
    const verb =
      actions && acted.length > 0
        ? verbFor(actions(acted), e, platform)
        : undefined
    if (verb) return run(verb.onSelect)
    const step = (dRow: number, dColumn: number) =>
      run(() =>
        moveTo(
          mod
            ? edge(here, dRow, dColumn)
            : { row: here.row + dRow, column: here.column + dColumn },
          grow,
        ),
      )
    const letter = e.key.length === 1 ? e.key.toLowerCase() : ""
    if (mod && !e.altKey)
      switch (letter) {
        case "a":
          return run(() => select(all(order, selection)))
        case "g":
          return run(() => setGoing(true))
        case "f":
          return run(() => {
            setFind((f) => f ?? { query: "", at: 0 })
            findInput.current?.select()
          })
        case "z":
          // Staged steps first: they are the newest, and the consumer has not seen them.
          if ((e.shiftKey ? stage.undone : stage.done).length > 0)
            return run(() => unstage(!e.shiftKey))
          if (e.shiftKey ? !onRedo : !onUndo) return
          return run(() => (e.shiftKey ? onRedo?.() : onUndo?.()))
        case "s":
          if (!staged || changed.length === 0) return
          return run(apply)
        case "d":
        case "r": {
          if (!onEdit) return
          const down = letter === "d"
          if (down ? block.top === block.bottom : block.left === block.right)
            return
          return run(() =>
            change(
              overBlock((r, c) =>
                down ? textAt(block.top, c) : textAt(r, block.left),
              ),
            ),
          )
        }
      }
    switch (e.key) {
      case "ArrowDown":
        return step(1, 0)
      case "ArrowUp":
        return step(-1, 0)
      case "ArrowRight":
        return step(0, 1)
      case "ArrowLeft":
        return step(0, -1)
      case "Home":
        return run(() =>
          moveTo(mod ? { row: 0, column: 0 } : { ...here, column: 0 }, grow),
        )
      case "End":
        return run(() =>
          moveTo(
            mod
              ? { row: order.length - 1, column: columns.length - 1 }
              : { ...here, column: columns.length - 1 },
            grow,
          ),
        )
      case "PageDown":
        return run(() => moveTo({ ...here, row: here.row + page }, grow))
      case "PageUp":
        return run(() => moveTo({ ...here, row: here.row - page }, grow))
      case "Tab": {
        const next = here.column + (e.shiftKey ? -1 : 1)
        if (next >= 0 && next < columns.length)
          return run(() => moveTo({ ...here, column: next }))
        const wrap = here.row + (e.shiftKey ? -1 : 1)
        if (wrap < 0 || wrap >= order.length) return
        return run(() =>
          moveTo({
            row: wrap,
            column: e.shiftKey ? columns.length - 1 : 0,
          }),
        )
      }
      case "Enter":
      case "F2":
        if (!cursor) return
        return run(() => startEdit(here))
      case " ":
        if (!cursor) return
        if (e.shiftKey) {
          const ids = order.slice(block.top, block.bottom + 1)
          return run(() =>
            select({
              ids: new Set(ids),
              anchor: ids[0],
              cursor: ids.at(-1),
            }),
          )
        }
        return run(() =>
          kindOfColumn(cursor.column) === "boolean" &&
          editable(columns[here.column] as TableColumn<R>)
            ? flip(here)
            : select(toggle(selection, cursor.row)),
        )
      case "Escape":
        if (anchor) return run(() => setAnchor(null))
        if (selection.ids.size === 0) return
        return run(() => select({ ...NONE, anchor: selection.anchor }))
      case "Backspace":
      case "Delete":
        if (!cursor || mod || !onEdit) return
        return run(() => change(overBlock(() => "")))
      case "ContextMenu":
      case "F10":
        if (e.key === "F10" && !e.shiftKey) return
        if (!cursor || !actions) return
        return run(() => {
          const el = document.getElementById(
            cellId(scope, cursor.row, cursor.column),
          )
          if (el)
            setMenu({
              entries: actions(actsOn(order, selection, cursor.row)),
              at: el,
            })
        })
    }
    if (
      cursor &&
      letter &&
      !mod &&
      !e.altKey &&
      editable(columns[here.column] as TableColumn<R>) &&
      kindOfColumn(cursor.column) !== "boolean"
    )
      run(() => startEdit(here, e.key))
  }

  const onCopy = (e: React.ClipboardEvent) => {
    if (typingIn(e.target) || !cursor) return
    const line = (r: number, from: number, to: number) => {
      const cells: string[] = []
      for (let c = from; c <= to; c++) cells.push(textAt(r, c))
      return cells.join("\t")
    }
    const lines: string[] = []
    if (!wide && chosen.length > 0)
      for (const id of chosen)
        lines.push(line(index.get(id) as number, 0, columns.length - 1))
    else
      for (let r = block.top; r <= block.bottom; r++)
        lines.push(line(r, block.left, block.right))
    e.preventDefault()
    e.clipboardData.setData("text/plain", lines.join("\n"))
  }
  const onPaste = (e: React.ClipboardEvent) => {
    if (typingIn(e.target) || !cursor || !onEdit) return
    const m = matrixOf(e.clipboardData.getData("text/plain"))
    e.preventDefault()
    // One value fills the block; a block of text lands from the block's corner.
    if (m.length === 1 && (m[0] as string[]).length === 1)
      return change(overBlock(() => (m[0] as string[])[0] as string))
    const edits: CellEdit[] = []
    m.forEach((line, dr) => {
      line.forEach((text, dc) => {
        const r = block.top + dr
        const c = block.left + dc
        if (r >= order.length || c >= columns.length) return
        if (!editable(columns[c] as TableColumn<R>)) return
        edits.push({ ...cellAt({ row: r, column: c }), text })
      })
    })
    change(edits)
  }

  // ── The pointer.
  const onCellDown = (at: At, e: React.PointerEvent) => {
    if (e.button !== 0) return
    const cell = cellAt(at)
    if (editing?.row === cell.row && editing.column === cell.column) return
    e.preventDefault()
    focusGrid()
    if (e.metaKey || e.ctrlKey) {
      setCursor(cell)
      return select(toggle(selection, cell.row))
    }
    if (e.shiftKey) return moveTo(at, true)
    moveTo(at)
    setAnchor(cell)
    dragging.current = true
  }
  const onCellEnter = (at: At, e: React.PointerEvent) => {
    if (!dragging.current || !(e.buttons & 1)) return
    setCursor(cellAt(at))
  }
  const onGutterClick = (row: string, e: React.MouseEvent) => {
    const mod = e.metaKey || e.ctrlKey
    setCursor({ row, column: cursor?.column ?? (columns[0]?.id as string) })
    setAnchor(null)
    focusGrid()
    select(
      e.shiftKey
        ? range(order, selection, row, mod)
        : mod
          ? toggle(selection, row)
          : only(row),
    )
  }
  const onRowMenu = (row: string, e: React.MouseEvent) => {
    if (!actions) return
    e.preventDefault()
    setMenu({
      entries: actions(actsOn(order, selection, row)),
      at: { x: e.clientX, y: e.clientY },
    })
  }
  const cycleSort = (column: string) =>
    setSort((s) =>
      s?.column !== column
        ? { column, direction: "asc" }
        : s.direction === "asc"
          ? { column, direction: "desc" }
          : null,
    )
  const headerMenu = (column: string, at: { x: number; y: number }) => {
    const i = columnIndex.get(column) as number
    const moves: MenuEntry[] = [
      {
        icon: <ArrowLeft />,
        label: "move left",
        disabled: i === 0,
        onSelect: () => moveColumn(column, i - 1),
      },
      {
        icon: <ArrowRight />,
        label: "move right",
        disabled: i === columns.length - 1,
        onSelect: () => moveColumn(column, i + 1),
      },
    ]
    const more = columnActions?.(column) ?? []
    setMenu({
      at,
      entries: [
        {
          icon: <ArrowUpNarrowWide />,
          label: "sort ascending",
          onSelect: () => setSort({ column, direction: "asc" }),
        },
        {
          icon: <ArrowDownWideNarrow />,
          label: "sort descending",
          onSelect: () => setSort({ column, direction: "desc" }),
        },
        ...(sort?.column === column
          ? [
              {
                icon: <X />,
                label: "as given",
                onSelect: () => setSort(null),
              },
            ]
          : []),
        "separator",
        ...moves,
        ...(more.length ? ["separator" as const, ...more] : []),
      ],
    })
  }

  // A header dragged sideways moves its column: past a few pixels it is a drag, not a
  // click (the sort waits for a click); the column lands before the header the
  // pointer is over the first half of.
  const dragHead = (from: number, e: React.PointerEvent) => {
    if (e.button !== 0) return
    const target = e.target as HTMLElement
    if (target.closest("[data-slot=table-resize], input")) return
    const x0 = e.clientX
    let to: number | null = null
    dragged.current = false
    const move = (m: PointerEvent) => {
      if (!dragged.current && Math.abs(m.clientX - x0) < 4) return
      dragged.current = true
      const heads = [
        ...(headRow.current?.querySelectorAll<HTMLElement>(
          "[data-slot=table-head]",
        ) ?? []),
      ]
      const at = heads.findIndex((h) => {
        const r = h.getBoundingClientRect()
        return m.clientX < r.left + r.width / 2
      })
      to = at < 0 ? heads.length : at
      setDrop({ from, to })
    }
    const up = () => {
      window.removeEventListener("pointermove", move)
      window.removeEventListener("pointerup", up)
      setDrop(null)
      if (!dragged.current || to === null) return
      const landing = to > from ? to - 1 : to
      if (landing !== from)
        moveColumn((columns[from] as TableColumn<R>).id, landing)
    }
    window.addEventListener("pointermove", move)
    window.addEventListener("pointerup", up)
  }

  // ── Find: every cell holding the words, in the shown order, row by row.
  const matches = React.useMemo(() => {
    const q = find?.query.trim().toLowerCase()
    if (!q) return []
    const found: At[] = []
    order.forEach((id, row) => {
      const r = byId.get(id) as R
      columns.forEach((c, column) => {
        if (c.text(r).toLowerCase().includes(q)) found.push({ row, column })
      })
    })
    return found
  }, [find?.query, order, byId, columns])
  const matched = React.useMemo(
    () => new Set(matches.map((m) => `${m.row}:${m.column}`)),
    [matches],
  )
  // What is typed is found at once: the cursor goes to the first match, as a browser's
  // find does, and Enter walks on from there.
  // biome-ignore lint/correctness/useExhaustiveDependencies: only a new set of matches moves the cursor
  React.useEffect(() => {
    const found = matches[0]
    if (found) moveTo(found)
  }, [matches])
  const showMatch = (at: number) => {
    if (matches.length === 0) return
    const i = ((at % matches.length) + matches.length) % matches.length
    setFind((f) => (f ? { ...f, at: i } : f))
    moveTo(matches[i] as At)
  }

  // A column's edge: dragged, it sets the width; double-clicked, it fits again.
  const resize = (column: string, e: React.PointerEvent<HTMLElement>) => {
    e.preventDefault()
    e.stopPropagation()
    const head = e.currentTarget.parentElement as HTMLElement
    const from = head.getBoundingClientRect().width
    const x0 = e.clientX
    const el = e.currentTarget
    el.setPointerCapture(e.pointerId)
    const move = (m: PointerEvent) =>
      setWidths((w) => ({
        ...w,
        [column]: Math.max(MIN_PX, Math.round(from + m.clientX - x0)),
      }))
    const up = () => {
      el.removeEventListener("pointermove", move)
      el.removeEventListener("pointerup", up)
      el.removeEventListener("pointercancel", up)
    }
    el.addEventListener("pointermove", move)
    el.addEventListener("pointerup", up)
    el.addEventListener("pointercancel", up)
  }
  const fit = (column: string) =>
    setWidths((w) => {
      const { [column]: _, ...rest } = w
      return rest
    })

  const firstWidth = tracks[0] ?? "0px"
  return (
    <div
      ref={scroller}
      data-slot="table"
      onScroll={(e) => {
        const top = e.currentTarget.scrollTop
        if (top !== view.top) setView((v) => ({ ...v, top }))
      }}
      className={cn(
        // edge: its foot holds the pinned bars (refused, staged, chosen), which a
        // fade on this scroller would fade with the rows; owed: the bars moved
        // outside it, then `ag-edge-fade-xy` with the header and the first
        // column as its covers.
        "relative flex min-h-0 flex-col overflow-auto text-[13px]",
        NO_SCROLLBAR,
        GROUND,
        !interactive && "pointer-events-none",
        className,
      )}
      style={
        {
          "--ag-table-cols": template,
          "--ag-table-row": `${rowPx}px`,
          "--ag-table-gutter": `${gutterCh}ch`,
          "--ag-table-first": firstWidth,
          "--ag-table-view": `${view.width}px`,
        } as React.CSSProperties
      }
    >
      {interactive && <ScrollThumbs scroller={scroller} />}
      {interactive && find && (
        // Pinned over the view's top right corner whichever way it scrolls.
        <div className="pointer-events-none sticky top-0 left-0 z-40 h-0 w-(--ag-table-view) shrink-0">
          <search
            data-slot="table-find"
            className="pointer-events-auto absolute top-1 right-2 flex items-center gap-1 rounded-lg border border-border bg-popover/95 py-0.5 pr-0.5 pl-2 text-[12px] text-popover-foreground shadow-md backdrop-blur-md"
          >
            <input
              ref={findInput}
              // biome-ignore lint/a11y/noAutofocus: find opens on the key that asked for it
              autoFocus
              aria-label="find in the table"
              placeholder="find"
              value={find.query}
              onChange={(e) => {
                const query = e.target.value
                setFind({ query, at: 0 })
              }}
              onKeyDown={(e) => {
                e.stopPropagation()
                if (e.key === "Escape") {
                  e.preventDefault()
                  setFind(null)
                  focusGrid()
                  return
                }
                if (e.key !== "Enter") return
                e.preventDefault()
                showMatch(find.at + (e.shiftKey ? -1 : 1))
              }}
              className="w-36 min-w-0 bg-transparent outline-none placeholder:text-foreground/35"
            />
            <span className="px-1 text-foreground/45 tabular-nums">
              {find.query.trim() === ""
                ? ""
                : matches.length === 0
                  ? "none"
                  : `${find.at + 1} of ${matches.length}`}
            </span>
            <button
              type="button"
              aria-label="previous match"
              disabled={matches.length === 0}
              onClick={() => showMatch(find.at - 1)}
              className="grid size-6 cursor-pointer place-items-center rounded-md text-foreground/55 hover:bg-foreground/8 hover:text-foreground disabled:opacity-40"
            >
              <ArrowUp className="size-3.5" />
            </button>
            <button
              type="button"
              aria-label="next match"
              disabled={matches.length === 0}
              onClick={() => showMatch(find.at + 1)}
              className="grid size-6 cursor-pointer place-items-center rounded-md text-foreground/55 hover:bg-foreground/8 hover:text-foreground disabled:opacity-40"
            >
              <ArrowDown className="size-3.5" />
            </button>
            <button
              type="button"
              aria-label="close find"
              onClick={() => {
                setFind(null)
                focusGrid()
              }}
              className="grid size-6 cursor-pointer place-items-center rounded-md text-foreground/55 hover:bg-foreground/8 hover:text-foreground"
            >
              <X className="size-3.5" />
            </button>
          </search>
        </div>
      )}
      <table
        ref={grid}
        // biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: WAI-ARIA's data grid is a table given the grid role; its cells are walked with the keys
        role="grid"
        aria-label={label}
        aria-rowcount={order.length + 1}
        aria-colcount={columns.length}
        aria-multiselectable={interactive || undefined}
        aria-readonly={!onEdit || undefined}
        aria-activedescendant={
          cursor ? cellId(scope, cursor.row, cursor.column) : undefined
        }
        tabIndex={interactive ? 0 : -1}
        onKeyDown={interactive ? onKeyDown : undefined}
        onCopy={interactive ? onCopy : undefined}
        onPaste={interactive ? onPaste : undefined}
        onFocus={() => {
          if (!cursor && order.length > 0 && columns.length > 0)
            setCursor(cellAt({ row: 0, column: 0 }))
        }}
        className="group/table relative block w-max min-w-full shrink-0 border-collapse outline-none select-none"
      >
        <thead
          data-slot="table-header"
          className={cn("sticky top-0 z-20 block", GROUND)}
        >
          <tr
            ref={headRow}
            aria-rowindex={1}
            className="grid h-(--ag-table-row) grid-cols-(--ag-table-cols)"
          >
            <th
              data-slot="table-address"
              className={cn(
                HEAD,
                GROUND,
                "sticky left-0 z-30 justify-end px-1.5 font-normal text-[11px] text-foreground/40 tabular-nums",
              )}
            >
              {going ? (
                <input
                  // biome-ignore lint/a11y/noAutofocus: go to opens on the key that asked for it
                  autoFocus
                  aria-label="go to a cell, a row, a column or a block"
                  placeholder={address}
                  onKeyDown={(e) => {
                    e.stopPropagation()
                    if (e.key === "Escape") {
                      setGoing(false)
                      focusGrid()
                    }
                    if (e.key !== "Enter") return
                    e.preventDefault()
                    if (!goTo(e.currentTarget.value)) {
                      e.currentTarget.setAttribute("aria-invalid", "true")
                      return
                    }
                    setGoing(false)
                    focusGrid()
                  }}
                  onInput={(e) =>
                    e.currentTarget.removeAttribute("aria-invalid")
                  }
                  onBlur={() => setGoing(false)}
                  className="absolute inset-0 min-w-0 bg-background px-1.5 text-right text-foreground uppercase outline-[1.5px] -outline-offset-2 outline-ring placeholder:text-foreground/30 aria-invalid:outline-tone-bad"
                />
              ) : (
                <span className="truncate" title="go to (mod+g)">
                  {address}
                </span>
              )}
            </th>
            {columns.map((c, i) => {
              const sortedHere = sort?.column === c.id ? sort.direction : null
              const kind = kindOfColumn(c.id)
              return (
                <th
                  key={c.id}
                  scope="col"
                  aria-colindex={i + 1}
                  aria-sort={
                    sortedHere === "asc"
                      ? "ascending"
                      : sortedHere === "desc"
                        ? "descending"
                        : undefined
                  }
                  data-slot="table-head"
                  data-dragging={drop?.from === i || undefined}
                  data-drop={
                    drop && drop.to !== drop.from && drop.to !== drop.from + 1
                      ? drop.to === i
                        ? "before"
                        : drop.to === columns.length && i === columns.length - 1
                          ? "after"
                          : undefined
                      : undefined
                  }
                  onPointerDown={(e) => interactive && dragHead(i, e)}
                  onClick={() => {
                    if (!interactive) return
                    if (dragged.current) {
                      dragged.current = false
                      return
                    }
                    cycleSort(c.id)
                  }}
                  onDoubleClick={() =>
                    interactive && onRename && setNaming(c.id)
                  }
                  onContextMenu={(e) => {
                    if (!interactive) return
                    e.preventDefault()
                    headerMenu(c.id, { x: e.clientX, y: e.clientY })
                  }}
                  className={cn(
                    HEAD,
                    "cursor-default select-none hover:text-foreground/80 data-dragging:opacity-40",
                    // where a dragged column would land: a line at the edge
                    "data-[drop=before]:before:absolute data-[drop=before]:before:inset-y-1 data-[drop=before]:before:-left-px data-[drop=before]:before:w-0.5 data-[drop=before]:before:rounded-full data-[drop=before]:before:bg-foreground/60",
                    "data-[drop=after]:before:absolute data-[drop=after]:before:inset-y-1 data-[drop=after]:before:-right-px data-[drop=after]:before:w-0.5 data-[drop=after]:before:rounded-full data-[drop=after]:before:bg-foreground/60",
                    i === 0 &&
                      cn(GROUND, "sticky left-(--ag-table-gutter) z-30"),
                  )}
                >
                  <span
                    aria-hidden="true"
                    className="shrink-0 font-normal text-[10px] text-foreground/30"
                  >
                    {columnName(i)}
                  </span>
                  {naming === c.id ? (
                    <input
                      // biome-ignore lint/a11y/noAutofocus: a rename opens on the gesture that asked for it
                      autoFocus
                      aria-label={`rename ${c.label}`}
                      defaultValue={c.label}
                      onClick={(e) => e.stopPropagation()}
                      onKeyDown={(e) => {
                        e.stopPropagation()
                        if (e.key === "Enter") e.currentTarget.blur()
                        if (e.key === "Escape") {
                          e.currentTarget.value = c.label
                          e.currentTarget.blur()
                        }
                      }}
                      onBlur={(e) => {
                        const v = e.currentTarget.value.trim()
                        if (v && v !== c.label) onRename?.(c.id, v)
                        setNaming(null)
                        focusGrid()
                      }}
                      className="absolute inset-0 bg-background px-2.5 font-medium text-foreground outline-2 -outline-offset-2 outline-ring"
                    />
                  ) : (
                    <span
                      className={cn(
                        "min-w-0 flex-1 truncate",
                        kind === "number" && "text-right",
                        kind === "boolean" && "text-center",
                      )}
                    >
                      {c.label}
                    </span>
                  )}
                  {sortedHere &&
                    (sortedHere === "asc" ? (
                      <ArrowUp className="size-3 shrink-0" />
                    ) : (
                      <ArrowDown className="size-3 shrink-0" />
                    ))}
                  {interactive && (
                    <span
                      aria-hidden="true"
                      data-slot="table-resize"
                      onPointerDown={(e) => resize(c.id, e)}
                      onClick={(e) => e.stopPropagation()}
                      onDoubleClick={(e) => {
                        e.stopPropagation()
                        fit(c.id)
                      }}
                      className="absolute inset-y-1 -right-1 z-10 w-2 cursor-col-resize after:absolute after:inset-y-0 after:left-1/2 after:w-px after:bg-foreground/10 hover:after:bg-foreground/40"
                    />
                  )}
                </th>
              )
            })}
            {onAddColumn && interactive && (
              <th className={cn(HEAD, "justify-center px-0")}>
                <button
                  type="button"
                  aria-label="add a column"
                  onClick={onAddColumn}
                  className="grid size-6 cursor-pointer place-items-center rounded-md hover:bg-foreground/8 hover:text-foreground"
                >
                  <Plus className="size-3.5" />
                </button>
              </th>
            )}
          </tr>
        </thead>
        <tbody
          data-slot="table-body"
          className="relative block h-(--ag-table-body)"
          style={
            {
              "--ag-table-body": `${order.length * rowPx}px`,
            } as React.CSSProperties
          }
        >
          {mounted.map((i) => {
            const id = order[i] as string
            const row = byId.get(id) as R
            const selected = selection.ids.has(id)
            return (
              <tr
                key={id}
                aria-rowindex={i + 2}
                aria-selected={interactive ? selected : undefined}
                data-slot="table-row"
                data-selected={selected || undefined}
                onContextMenu={(e) => interactive && onRowMenu(id, e)}
                className="absolute inset-x-0 top-0 grid h-(--ag-table-row) translate-y-(--ag-table-top) grid-cols-(--ag-table-cols) bg-(--ag-table-tint)"
                style={
                  {
                    "--ag-table-top": `${i * rowPx}px`,
                    "--ag-table-tint": selected
                      ? SELECTED_TINT
                      : (tint?.(row) ?? "transparent"),
                  } as React.CSSProperties
                }
              >
                <th
                  scope="row"
                  onClick={(e) => onGutterClick(id, e)}
                  className={cn(
                    CELL,
                    STICKY,
                    "left-0 cursor-default justify-end text-[11px] text-foreground/35 tabular-nums",
                    selected && "text-foreground/70",
                  )}
                >
                  {i + 1}
                </th>
                {columns.map((c, ci) => {
                  const kind = kindOfColumn(c.id)
                  const text = c.text(row)
                  const at = { row: i, column: ci }
                  const isCursor = cursor?.row === id && cursor.column === c.id
                  const inBlock =
                    wide &&
                    i >= block.top &&
                    i <= block.bottom &&
                    ci >= block.left &&
                    ci <= block.right
                  const edit =
                    editing?.row === id && editing.column === c.id
                      ? editing
                      : null
                  const k = cellKey(id, c.id)
                  const options = choices.get(c.id) ?? null
                  // Held: the cell shows text the consumer's rows do not hold yet,
                  // so it is drawn from that text, never from the row (`render`).
                  const isHeld = held.has(k)
                  const was = stagedText.has(k) ? baseOf(id, c.id) : undefined
                  const isStaged = was !== undefined && was !== text
                  const isPending = !isStaged && pending.has(k)
                  const isRefused = refused?.cells.has(k) ?? false
                  return (
                    // biome-ignore lint/a11y/useKeyWithClickEvents: the grid owns the keys
                    <td
                      key={c.id}
                      id={cellId(scope, id, c.id)}
                      aria-colindex={ci + 1}
                      data-slot="table-cell"
                      data-kind={kind}
                      data-cursor={(interactive && isCursor) || undefined}
                      data-block={inBlock || undefined}
                      data-match={matched.has(`${i}:${ci}`) || undefined}
                      data-staged={isStaged || undefined}
                      data-pending={isPending || undefined}
                      data-refused={isRefused || undefined}
                      title={
                        isRefused
                          ? `not saved: ${refused?.reason}`
                          : isStaged
                            ? `was ${was === "" ? "empty" : was}`
                            : undefined
                      }
                      onPointerDown={(e) => onCellDown(at, e)}
                      onPointerEnter={(e) => onCellEnter(at, e)}
                      onClick={(e) => {
                        if (
                          !e.shiftKey &&
                          !e.metaKey &&
                          !e.ctrlKey &&
                          kind === "boolean" &&
                          (e.target as HTMLElement).closest(
                            "[data-slot=table-check]",
                          )
                        )
                          flip(at)
                      }}
                      onDoubleClick={() => startEdit(at)}
                      className={cn(
                        CELL,
                        CURSOR,
                        BLOCK,
                        MATCH,
                        HELD,
                        "scroll-mt-(--ag-table-row) scroll-ml-[calc(var(--ag-table-gutter)+var(--ag-table-first))]",
                        kind === "number" && "justify-end tabular-nums",
                        kind === "date" && "tabular-nums",
                        kind === "boolean" && "justify-center",
                        ci === 0 &&
                          cn(STICKY, "left-(--ag-table-gutter) scroll-ml-0"),
                      )}
                    >
                      {edit && options ? (
                        <ChoiceEditor
                          label={c.label}
                          draft={edit.draft}
                          typed={edit.typed}
                          options={options}
                          onDone={(value, key, back) =>
                            finish(at, value, key, back)
                          }
                          onBlur={commit}
                        />
                      ) : edit ? (
                        <input
                          // biome-ignore lint/a11y/noAutofocus: an edit opens on the gesture that asked for it
                          autoFocus
                          aria-label={c.label}
                          defaultValue={edit.draft}
                          onFocus={(e) => {
                            const end = e.currentTarget.value.length
                            e.currentTarget.setSelectionRange(end, end)
                          }}
                          onKeyDown={(e) => {
                            if (
                              e.key !== "Escape" &&
                              e.key !== "Enter" &&
                              e.key !== "Tab"
                            )
                              return
                            e.preventDefault()
                            finish(
                              at,
                              e.key === "Escape" ? null : e.currentTarget.value,
                              e.key,
                              e.shiftKey,
                            )
                          }}
                          onBlur={(e) => commit(e.currentTarget.value)}
                          className={cn(
                            EDITOR,
                            kind === "number" && "text-right tabular-nums",
                          )}
                        />
                      ) : c.render && !isHeld ? (
                        c.render(row)
                      ) : kind === "boolean" ? (
                        <Box on={truthOf(text) === true} label={text} />
                      ) : options ? (
                        text.trim() !== "" && <Badge>{text}</Badge>
                      ) : (
                        <span className="truncate">{text}</span>
                      )}
                      {isStaged && (
                        <span
                          data-slot="table-staged"
                          className="pointer-events-none absolute top-0 right-0 size-2 bg-foreground/45 [clip-path:polygon(0_0,100%_0,100%_100%)]"
                        >
                          <span className="sr-only">
                            changed, was {was === "" ? "empty" : was}
                          </span>
                        </span>
                      )}
                    </td>
                  )
                })}
              </tr>
            )
          })}
        </tbody>
      </table>
      {onAddRow && interactive && (
        <button
          type="button"
          onClick={onAddRow}
          className="sticky left-0 flex h-(--ag-table-row) w-fit shrink-0 cursor-pointer items-center gap-1.5 px-2.5 text-[12px] text-foreground/45 hover:text-foreground"
        >
          <Plus className="size-3.5" />
          add a {noun}
        </button>
      )}
      {interactive &&
        (changed.length > 0 || refused || (actions && chosen.length > 0)) && (
          // Pinned to the view's foot, one place whatever the table's length and
          // whichever way it scrolls: a box of no height pushed to the end of the
          // column (mt-auto: a short table does not lift it), sticky to the bottom and
          // the left, the view's width. The bars stack there: what was refused, what
          // waits to be applied, what is selected.
          <div className="pointer-events-none sticky bottom-0 left-0 z-30 mt-auto h-0 w-(--ag-table-view) shrink-0">
            <div className="absolute inset-x-3 bottom-3 flex flex-col items-center gap-2 *:pointer-events-auto">
              {refused && (
                <div
                  role="alert"
                  data-slot="table-refused"
                  className={cn(BAR, "pl-2.5 text-[13px]")}
                >
                  <span className="text-tone-bad">
                    {refused.cells.size === 1
                      ? "a cell was"
                      : `${refused.cells.size} cells were`}{" "}
                    not saved
                  </span>
                  <span className="px-1.5 text-foreground/60">
                    {refused.reason}
                  </span>
                  <span aria-hidden="true" className={BAR_RULE} />
                  <BarVerb
                    entry={{
                      icon: <X />,
                      label: "dismiss",
                      onSelect: () => setRefused(null),
                    }}
                  />
                </div>
              )}
              {changed.length > 0 && (
                <div
                  role="toolbar"
                  aria-label="unsaved changes"
                  data-slot="table-staged-bar"
                  className={BAR}
                >
                  <span className="px-2 text-[13px] font-medium tabular-nums">
                    {changed.length}{" "}
                    {changed.length === 1 ? "change" : "changes"}
                  </span>
                  <span aria-hidden="true" className={BAR_RULE} />
                  <BarVerb
                    entry={{
                      icon: <Undo2 />,
                      label: "discard",
                      onSelect: () => {
                        discard()
                        focusGrid()
                      },
                    }}
                  />
                  <BarVerb
                    entry={{
                      icon: <Check />,
                      label: "apply",
                      hint: "mod+s",
                      onSelect: () => {
                        apply()
                        focusGrid()
                      },
                    }}
                  />
                </div>
              )}
              {actions && chosen.length > 0 && (
                <SelectionBar
                  count={chosen.length}
                  hidden={selection.ids.size - chosen.length}
                  noun={noun}
                  entries={actions(chosen)}
                  onClear={() => {
                    select({ ...NONE, anchor: selection.anchor })
                    focusGrid()
                  }}
                />
              )}
            </div>
          </div>
        )}
      {interactive && (
        <Menu
          entries={menu?.entries ?? []}
          anchor={menu?.at ?? null}
          onClose={() => setMenu(null)}
          returnTo={() => grid.current}
        />
      )}
    </div>
  )
}

/** A choice's value: a quiet pill, so a column of a few values reads as states. */
function Badge({ children }: { children: string }) {
  return (
    <span
      data-slot="table-badge"
      className="truncate rounded-full bg-foreground/7 px-2 py-px text-[0.92em] text-foreground/75"
    >
      {children}
    </span>
  )
}

/** A choice cell, edited: what is typed narrows the column's values, ↑ ↓ walk them,
 *  Enter or Tab take the one marked (what was typed when none is), a press on one
 *  takes it, Esc gives up. The list hangs under the cell outside the table, so the
 *  table's edge never clips it. */
function ChoiceEditor({
  label,
  draft,
  typed,
  options,
  onDone,
  onBlur,
}: {
  label: string
  draft: string
  typed: boolean
  options: readonly string[]
  onDone: (value: string | null, key: string, back: boolean) => void
  onBlur: (value: string) => void
}) {
  const id = React.useId()
  const input = React.useRef<HTMLInputElement>(null)
  const [query, setQuery] = React.useState(draft)
  // Opened on a cell, every value is offered with the cell's own marked; opened by
  // typing, only the ones holding what is typed.
  const [narrowed, setNarrowed] = React.useState(typed)
  // The values holding what is typed, then, when what is typed is none of them, the
  // typed text itself as the last entry: a new value is chosen like any other.
  const typedText = query.trim()
  const entries = [
    ...(narrowed
      ? options.filter((o) => o.toLowerCase().includes(query.toLowerCase()))
      : options
    ).map((value) => ({ value, fresh: false })),
    ...(typedText !== "" && !options.includes(typedText)
      ? [{ value: typedText, fresh: true }]
      : []),
  ]
  const [marked, setMarked] = React.useState(() =>
    typed ? 0 : Math.max(0, options.indexOf(draft)),
  )
  // -1: nothing marked (the field typed empty, so Enter empties the cell).
  const at = entries.length === 0 || marked < 0 ? -1 : marked % entries.length
  const [place, setPlace] = React.useState<DOMRect | null>(null)
  React.useLayoutEffect(() => {
    const cell = input.current?.closest("td")
    if (!cell) return
    const read = () => setPlace(cell.getBoundingClientRect())
    read()
    window.addEventListener("scroll", read, true)
    window.addEventListener("resize", read)
    return () => {
      window.removeEventListener("scroll", read, true)
      window.removeEventListener("resize", read)
    }
  }, [])
  const choice = (i: number) => `${id}-${i}`
  return (
    <>
      <input
        ref={input}
        // biome-ignore lint/a11y/noAutofocus: an edit opens on the gesture that asked for it
        autoFocus
        role="combobox"
        aria-label={label}
        aria-expanded={entries.length > 0}
        aria-controls={id}
        aria-autocomplete="list"
        aria-activedescendant={at >= 0 ? choice(at) : undefined}
        value={query}
        onChange={(e) => {
          setQuery(e.target.value)
          setNarrowed(true)
          setMarked(e.target.value.trim() === "" ? -1 : 0)
        }}
        onFocus={(e) => {
          const end = e.currentTarget.value.length
          e.currentTarget.setSelectionRange(end, end)
        }}
        onKeyDown={(e) => {
          if (e.key === "ArrowDown" || e.key === "ArrowUp") {
            e.preventDefault()
            const n = entries.length
            const down = e.key === "ArrowDown"
            if (n > 0)
              setMarked(
                at < 0 ? (down ? 0 : n - 1) : (at + (down ? 1 : -1) + n) % n,
              )
            return
          }
          if (e.key !== "Escape" && e.key !== "Enter" && e.key !== "Tab") return
          e.preventDefault()
          onDone(
            e.key === "Escape" ? null : (entries[at]?.value ?? query),
            e.key,
            e.shiftKey,
          )
        }}
        onBlur={() => onBlur(query)}
        className={EDITOR}
      />
      {place &&
        createPortal(
          // The frame stays whole; the list inside it scrolls, its cut edges
          // fading where there is more (scroll-area).
          <div
            data-slot="table-choices"
            className="fixed z-50 flex max-h-64 min-w-(--ag-choices-width) flex-col rounded-lg border border-border bg-popover/95 p-1 text-[13px] text-popover-foreground shadow-lg backdrop-blur-md"
            style={
              {
                top: place.bottom + 4,
                left: place.left,
                "--ag-choices-width": `${place.width}px`,
              } as React.CSSProperties
            }
          >
            <ScrollArea id={id} role="listbox" aria-label={label}>
              {entries.map(({ value, fresh }, i) => (
                <div
                  key={fresh ? `\u0000${value}` : value}
                  id={choice(i)}
                  role="option"
                  aria-selected={i === at}
                  tabIndex={-1}
                  // A press takes the value without taking focus from the field first.
                  onPointerDown={(e) => {
                    e.preventDefault()
                    onDone(value, "Enter", false)
                  }}
                  className="flex h-7 cursor-pointer items-center gap-2 rounded-md px-2 text-foreground/80 aria-selected:bg-foreground/8 aria-selected:text-foreground"
                >
                  {fresh ? (
                    <Plus className="size-3.5 shrink-0 opacity-50" />
                  ) : (
                    <Check
                      className={cn(
                        "size-3.5 shrink-0",
                        value === draft && !typed ? "opacity-70" : "opacity-0",
                      )}
                    />
                  )}
                  {fresh ? (
                    <span>
                      <span className="text-foreground/50">write </span>“{value}
                      ”
                    </span>
                  ) : (
                    value
                  )}
                </div>
              ))}
            </ScrollArea>
          </div>,
          document.body,
        )}
    </>
  )
}

/** A yes/no cell: a check that is pressed to flip it. */
function Box({ on, label }: { on: boolean; label: string }) {
  return (
    <span
      data-slot="table-check"
      data-on={on || undefined}
      className="grid size-3.5 cursor-pointer place-items-center rounded-[4px] border border-foreground/30 text-background data-[on]:border-foreground/80 data-[on]:bg-foreground/80"
    >
      {on && <Check className="size-2.5" strokeWidth={3.5} />}
      <span className="sr-only">{label}</span>
    </span>
  )
}

// ── A CSV, viewed and edited: its text in, the text written back on every change,
// every change one step of its history (⌘Z, ⇧⌘Z).

interface Sheet {
  csv: Omit<Csv, "header" | "rows">
  columns: { id: string; label: string }[]
  rows: { id: string; cells: Record<string, string> }[]
}

/** How many changes back ⌘Z reaches. */
const HISTORY = 200

export interface CsvTableProps
  extends Pick<
    TableProps<unknown>,
    "density" | "interactive" | "className" | "label"
  > {
  /** The file's text, read once; give the table a new `key` to load another. */
  text: string
  /** The text after every change. Without it the table only reads. */
  onChange?: (text: string) => void
}

const textOf = (s: Sheet) =>
  toCsv({
    ...s.csv,
    header: s.columns.map((c) => c.label),
    rows: s.rows.map((r) => s.columns.map((c) => r.cells[c.id] ?? "")),
  })

export function CsvTable({ text, onChange, ...props }: CsvTableProps) {
  const seq = React.useRef(0)
  const next = (prefix: string) => `${prefix}${seq.current++}`
  const [state, setState] = React.useState<{
    sheet: Sheet
    past: Sheet[]
    future: Sheet[]
  }>(() => {
    const { header, rows, ...csv } = parseCsv(text)
    const columns = header.map((label) => ({ id: next("c"), label }))
    return {
      sheet: {
        csv,
        columns,
        rows: rows.map((r) => ({
          id: next("r"),
          cells: Object.fromEntries(columns.map((c, i) => [c.id, r[i] ?? ""])),
        })),
      },
      past: [],
      future: [],
    }
  })
  const { sheet } = state
  const write = (s: Sheet) => {
    setState({
      sheet: s,
      past: [...state.past, sheet].slice(-HISTORY),
      future: [],
    })
    onChange?.(textOf(s))
  }
  const travel = (back: boolean) => {
    const from = back ? state.past : state.future
    const to = from.at(-1)
    if (!to) return
    setState(
      back
        ? {
            sheet: to,
            past: state.past.slice(0, -1),
            future: [...state.future, sheet],
          }
        : {
            sheet: to,
            past: [...state.past, sheet],
            future: state.future.slice(0, -1),
          },
    )
    onChange?.(textOf(to))
  }
  const columns = React.useMemo(
    () =>
      sheet.columns.map(
        (c): TableColumn<Sheet["rows"][number]> => ({
          id: c.id,
          label: c.label,
          text: (r) => r.cells[c.id] ?? "",
        }),
      ),
    [sheet.columns],
  )
  const blank = () => ({
    id: next("r"),
    cells: Object.fromEntries(sheet.columns.map((c) => [c.id, ""])),
  })
  const insertRow = (beside: string, after: boolean) => {
    const i = sheet.rows.findIndex((r) => r.id === beside) + (after ? 1 : 0)
    write({
      ...sheet,
      rows: [...sheet.rows.slice(0, i), blank(), ...sheet.rows.slice(i)],
    })
  }
  const insertColumn = (beside: string, after: boolean) => {
    const i = sheet.columns.findIndex((c) => c.id === beside) + (after ? 1 : 0)
    const column = {
      id: next("c"),
      label: `column ${sheet.columns.length + 1}`,
    }
    write({
      ...sheet,
      columns: [
        ...sheet.columns.slice(0, i),
        column,
        ...sheet.columns.slice(i),
      ],
    })
  }
  const many = (ids: readonly string[]) =>
    ids.length > 1 ? ` ${ids.length} rows` : ""
  if (!onChange)
    return (
      <Table {...props} rows={sheet.rows} rowId={rowIdOf} columns={columns} />
    )
  return (
    <Table
      {...props}
      rows={sheet.rows}
      rowId={rowIdOf}
      columns={columns}
      onEdit={(edits) => {
        const byRow = new Map<string, Record<string, string>>()
        for (const e of edits)
          byRow.set(e.row, { ...byRow.get(e.row), [e.column]: e.text })
        write({
          ...sheet,
          rows: sheet.rows.map((r) => {
            const patch = byRow.get(r.id)
            return patch ? { ...r, cells: { ...r.cells, ...patch } } : r
          }),
        })
      }}
      onUndo={() => travel(true)}
      onRedo={() => travel(false)}
      onRename={(column, label) =>
        write({
          ...sheet,
          columns: sheet.columns.map((c) =>
            c.id === column ? { ...c, label } : c,
          ),
        })
      }
      onMoveColumn={(column, to) => {
        const moved = sheet.columns.filter((c) => c.id !== column)
        moved.splice(
          to,
          0,
          sheet.columns.find(
            (c) => c.id === column,
          ) as Sheet["columns"][number],
        )
        write({ ...sheet, columns: moved })
      }}
      onAddRow={() => write({ ...sheet, rows: [...sheet.rows, blank()] })}
      onAddColumn={() => insertColumn(sheet.columns.at(-1)?.id ?? "", true)}
      actions={(ids) => [
        ...(ids.length === 1
          ? [
              {
                icon: <Rows3 />,
                label: "insert a row above",
                onSelect: () => insertRow(ids[0] as string, false),
              },
              {
                icon: <Rows3 />,
                label: "insert a row below",
                onSelect: () => insertRow(ids[0] as string, true),
              },
            ]
          : []),
        {
          icon: <Copy />,
          label: `duplicate${many(ids)}`,
          onSelect: () =>
            write({
              ...sheet,
              rows: sheet.rows.flatMap((r) =>
                ids.includes(r.id) ? [r, { ...r, id: next("r") }] : [r],
              ),
            }),
        },
        "separator",
        {
          icon: <Trash2 />,
          label: `delete${many(ids)}`,
          hint: "mod+backspace",
          destructive: true,
          onSelect: () =>
            write({
              ...sheet,
              rows: sheet.rows.filter((r) => !ids.includes(r.id)),
            }),
        },
      ]}
      columnActions={(column) => [
        {
          icon: <Plus />,
          label: "insert a column left",
          onSelect: () => insertColumn(column, false),
        },
        {
          icon: <Plus />,
          label: "insert a column right",
          onSelect: () => insertColumn(column, true),
        },
        "separator",
        {
          icon: <Trash2 />,
          label: "delete the column",
          destructive: true,
          disabled: sheet.columns.length === 1,
          onSelect: () =>
            write({
              ...sheet,
              columns: sheet.columns.filter((c) => c.id !== column),
              rows: sheet.rows.map((r) => {
                const { [column]: _, ...cells } = r.cells
                return { ...r, cells }
              }),
            }),
        },
      ]}
    />
  )
}

const rowIdOf = (r: { id: string }) => r.id
table-data.ts
// A TABLE'S DATA, framework-free: what kind a column is (read from its values, never
// declared), how two cells of a kind compare, and CSV in and out.
//
// KINDS ARE READ, NEVER DECLARED. A column is a number when every filled cell reads as
// one (a sign, a currency mark, thousands commas, a decimal point, a trailing %), a
// yes/no when every filled cell is one word of a single pair (yes/no, true/false, y/n),
// a date when every filled cell is an ISO date (a time may follow), else text. Empty
// cells say nothing either way; a column with no filled cell is text. The text itself
// is never rewritten: a number keeps its commas, a date its format, and the kind only
// decides alignment, figures, the sort and how a yes/no is flipped. A text column that
// repeats a few values (a status, a category) reads as choosing among them
// (`choicesOf`): its cells are offered those values when edited.
//
// CSV ROUND-TRIPS. `parseCsv` keeps what writing back needs (the delimiter it found,
// the line ending, whether the file ended in one), so `toCsv(parseCsv(text))` is the
// same text for any file that quotes only where it must. Rows shorter than the header
// are padded with empty cells; a row longer than the header screams, since there is no
// column to put the rest in.

export type ColumnKind = "text" | "number" | "boolean" | "date"

const NUMBER =
  /^[-+]?[$€£¥]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?%?$|^[-+]?[$€£¥]?\.\d+%?$/
const DATE =
  /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/
/** The yes/no pairs a column may speak, true word first. */
const PAIRS: readonly (readonly [string, string])[] = [
  ["yes", "no"],
  ["true", "false"],
  ["y", "n"],
]

/** A cell's number, or null when it is not one (commas, currency and % ignored). */
export function numberOf(text: string): number | null {
  const t = text.trim()
  if (!NUMBER.test(t)) return null
  return Number(t.replace(/[$€£¥,%]/g, ""))
}

/** A cell's instant in ms, or null when it is not an ISO date. */
export function dateOf(text: string): number | null {
  const t = text.trim()
  if (!DATE.test(t)) return null
  const ms = Date.parse(t.length === 10 ? `${t}T00:00:00Z` : t)
  return Number.isNaN(ms) ? null : ms
}

const pairOf = (text: string) => {
  const t = text.trim().toLowerCase()
  return PAIRS.findIndex(([y, n]) => t === y || t === n)
}

/** A yes/no cell's truth, or null when it is not one. */
export function truthOf(text: string): boolean | null {
  const p = pairOf(text)
  if (p < 0) return null
  return text.trim().toLowerCase() === (PAIRS[p] as readonly string[])[0]
}

/** The other word of a yes/no cell's pair, in the cell's own case ("Yes" → "No"). An
 *  empty cell flips to `truthy`, the column's true word. */
export function flipped(text: string, truthy: string): string {
  if (text.trim() === "") return truthy
  const p = pairOf(text)
  if (p < 0) throw new Error(`table-data: "${text}" is not a yes or a no`)
  const [y, n] = PAIRS[p] as readonly [string, string]
  const next = truthOf(text) ? n : y
  const t = text.trim()
  if (t === t.toUpperCase()) return next.toUpperCase()
  if (t[0] === (t[0] as string).toUpperCase())
    return (next[0] as string).toUpperCase() + next.slice(1)
  return next
}

/** The kind a column's cells say it is. */
export function kindOf(values: readonly string[]): ColumnKind {
  const filled = values.filter((v) => v.trim() !== "")
  if (filled.length === 0) return "text"
  if (filled.every((v) => numberOf(v) !== null)) return "number"
  const pair = pairOf(filled[0] as string)
  if (pair >= 0 && filled.every((v) => pairOf(v) === pair)) return "boolean"
  if (filled.every((v) => dateOf(v) !== null)) return "date"
  return "text"
}

/** The most values a column offers as choices. */
const CHOICES_MAX = 8

/** The values a text column chooses from, when its cells say it chooses: a few values
 *  (two to eight), filled at least four times, each used one and a half times on
 *  average (a status, a category; names and titles repeat too rarely). Sorted as text;
 *  null when the column reads as free text. */
export function choicesOf(values: readonly string[]): string[] | null {
  const filled = values.map((v) => v.trim()).filter((v) => v !== "")
  const distinct = [...new Set(filled)]
  if (
    filled.length < 4 ||
    distinct.length < 2 ||
    distinct.length > CHOICES_MAX ||
    distinct.length > filled.length / 1.5
  )
    return null
  return distinct.sort(collator.compare)
}

/** The true word a yes/no column writes into an empty cell: the case its cells use. */
export function truthyOf(values: readonly string[]): string {
  const sample = values.find((v) => pairOf(v) >= 0)
  if (!sample) return "yes"
  return truthOf(sample) ? sample.trim() : flipped(sample, "yes")
}

const collator = new Intl.Collator(undefined, {
  numeric: true,
  sensitivity: "base",
})

/** How two filled cells of a kind compare, ascending. */
export function compare(kind: ColumnKind, a: string, b: string): number {
  switch (kind) {
    case "number":
      return (numberOf(a) as number) - (numberOf(b) as number)
    case "date":
      return (dateOf(a) as number) - (dateOf(b) as number)
    case "boolean":
      return Number(truthOf(b)) - Number(truthOf(a))
    case "text":
      return collator.compare(a, b)
  }
}

export type SortDirection = "asc" | "desc"

/** `ids` sorted by their cells, empty cells last in either direction; equal cells keep
 *  their order. */
export function sorted(
  ids: readonly string[],
  textOf: (id: string) => string,
  kind: ColumnKind,
  direction: SortDirection,
): string[] {
  const sign = direction === "asc" ? 1 : -1
  return ids
    .map((id, i) => ({ id, i, text: textOf(id) }))
    .sort((a, b) => {
      const ea = a.text.trim() === ""
      const eb = b.text.trim() === ""
      if (ea || eb) return ea === eb ? a.i - b.i : ea ? 1 : -1
      return sign * compare(kind, a.text, b.text) || a.i - b.i
    })
    .map((r) => r.id)
}

// ── Addresses, a spreadsheet's: columns lettered A, B, … Z, AA, rows numbered from 1
// in the order shown. "B9" is a cell, "9" a row, "B" a column, "B2:D5" a block.

/** A column's letters by its place (0 → A, 26 → AA). */
export function columnName(at: number): string {
  if (!Number.isInteger(at) || at < 0)
    throw new Error(`table-data: no column at ${at}`)
  let name = ""
  for (let n = at + 1; n > 0; n = Math.floor((n - 1) / 26))
    name = String.fromCharCode(65 + ((n - 1) % 26)) + name
  return name
}

/** A column's place by its letters (A → 0), case ignored. */
export function columnAt(name: string): number {
  if (!/^[a-z]+$/i.test(name))
    throw new Error(`table-data: "${name}" is not a column`)
  return (
    [...name.toUpperCase()].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) -
    1
  )
}

/** One end of an address: a column, a row (both 0-based), or both. */
export interface Place {
  column?: number
  row?: number
}

/** What an address names, or null when it is not one: a cell, a row, a column, or a
 *  block from one to the other. */
export function parseAddress(text: string): { from: Place; to: Place } | null {
  const place = (t: string): Place | null => {
    const m = /^([a-z]*)(\d*)$/i.exec(t.trim())
    if (!m || (!m[1] && !m[2])) return null
    const [, letters, digits] = m
    if (digits && Number(digits) < 1) return null
    return {
      ...(letters ? { column: columnAt(letters) } : {}),
      ...(digits ? { row: Number(digits) - 1 } : {}),
    }
  }
  const [a, b, ...rest] = text.split(":")
  if (rest.length > 0 || a === undefined) return null
  const from = place(a)
  const to = b === undefined ? from : place(b)
  return from && to ? { from, to } : null
}

// ── CSV

export interface Csv {
  header: string[]
  rows: string[][]
  delimiter: "," | ";" | "\t"
  newline: "\n" | "\r\n"
  /** The text ended in a line break. */
  trailing: boolean
}

/** The delimiter the first line uses most, outside quotes. */
function delimiterOf(text: string): Csv["delimiter"] {
  const counts = { ",": 0, ";": 0, "\t": 0 }
  let quoted = false
  for (const c of text) {
    if (c === '"') quoted = !quoted
    else if (!quoted && (c === "\n" || c === "\r")) break
    else if (!quoted && c in counts) counts[c as keyof typeof counts]++
  }
  return counts[";"] > counts[","] && counts[";"] >= counts["\t"]
    ? ";"
    : counts["\t"] > counts[","]
      ? "\t"
      : ","
}

/** RFC 4180 text into a header and rows. The first record is the header. */
export function parseCsv(text: string): Csv {
  const delimiter = delimiterOf(text)
  const newline = text.includes("\r\n") ? "\r\n" : "\n"
  const records: string[][] = []
  let record: string[] = []
  let field = ""
  let quoted = false
  let i = 0
  while (i < text.length) {
    const c = text[i] as string
    if (quoted) {
      if (c === '"' && text[i + 1] === '"') {
        field += '"'
        i += 2
        continue
      }
      if (c === '"') quoted = false
      else field += c
      i++
      continue
    }
    if (c === '"' && field === "") quoted = true
    else if (c === delimiter) {
      record.push(field)
      field = ""
    } else if (c === "\n" || c === "\r") {
      record.push(field)
      records.push(record)
      record = []
      field = ""
      if (c === "\r" && text[i + 1] === "\n") i++
    } else field += c
    i++
  }
  if (quoted) throw new Error("table-data: a quote is never closed")
  const trailing = /\r?\n$/.test(text)
  if (!trailing && (field !== "" || record.length > 0)) {
    record.push(field)
    records.push(record)
  }
  const header = records[0] ?? []
  const rows = records.slice(1).map((r, n) => {
    if (r.length > header.length)
      throw new Error(
        `table-data: row ${n + 1} has ${r.length} cells, the header ${header.length}`,
      )
    return [...r, ...Array<string>(header.length - r.length).fill("")]
  })
  return { header, rows, delimiter, newline, trailing }
}

const fieldOf = (text: string, delimiter: string) =>
  text.includes('"') ||
  text.includes(delimiter) ||
  text.includes("\n") ||
  text.includes("\r")
    ? `"${text.replace(/"/g, '""')}"`
    : text

/** A header and rows back into the text, in the file's own delimiter and line ending,
 *  quoting only where a field must be quoted. */
export function toCsv(csv: Csv): string {
  const line = (r: readonly string[]) =>
    r.map((f) => fieldOf(f, csv.delimiter)).join(csv.delimiter)
  const text = [csv.header, ...csv.rows].map(line).join(csv.newline)
  return csv.trailing ? text + csv.newline : text
}