Vela UI Docsvenizia.ai

Doc Table

An editable grid with a header row. Row and column menus add, insert, move and delete; rows and columns drag to reorder, columns resize, and the table can span the window. Paste from a spreadsheet or web page, export CSV, Cmd+Enter adds a row and Cmd+Backspace deletes one.

npx shadcn@latest add @velaui/doc-table
npx shadcn@latest add @velaui/doc-table

Examples

  • Default
  • Paste
  • Resizable
  • Full width
  • Read only

View code

Doc Table · Default

Installation

pnpm dlx velaui add @velaui/doc-table
pnpm dlx velaui add @velaui/doc-table

Code

doc-table-default.tsx
import * as React from "react";

import { DocTable } from "@/components/ui/doc-table";

export default function Example() {
  const [rows, setRows] = React.useState([
    ["Task", "Owner", "Team", "Status", "Due"],
    ["Freeze the schema", "Linh", "Platform", "Done", "Oct 02"],
    ["Dry migration", "Minh", "Platform", "Done", "Oct 04"],
    ["Backfill invoices", "Hoa", "Billing", "Done", "Oct 07"],
    ["Rotate API keys", "Nam", "Security", "Done", "Oct 08"],
    ["Cut over EU region", "Linh", "Platform", "In progress", "Oct 11"],
    ["Update pricing page", "Mai", "Growth", "In progress", "Oct 11"],
    ["Finance sign-off", "Hoa", "Billing", "In progress", "Oct 14"],
    ["Load test checkout", "Duc", "Payments", "In progress", "Oct 15"],
    ["Migrate webhooks", "Nam", "Payments", "Blocked", "Oct 16"],
    ["Customer email", "Mai", "Growth", "Blocked", "Oct 17"],
    ["Train support team", "Thu", "Support", "To do", "Oct 18"],
    ["Update status page", "Duc", "Platform", "To do", "Oct 21"],
    ["Retire old endpoints", "Minh", "Platform", "To do", "Oct 25"],
    ["Post-launch review", "Thu", "Support", "To do", "Oct 30"],
  ]);
  return (
    <div className="w-full max-w-3xl p-6">
      <DocTable rows={rows} onChange={setRows} />
    </div>
  );
}

View code

Doc Table · Paste

Installation

pnpm dlx velaui add @velaui/doc-table
pnpm dlx velaui add @velaui/doc-table

Code

doc-table-paste.tsx
import * as React from "react";

import { DocTable } from "@/components/ui/doc-table";

export default function Example() {
  const [rows, setRows] = React.useState(Array.from({ length: 15 }, () => Array<string>(5).fill("")));
  return (
    <div className="w-full max-w-3xl p-6">
      <p className="text-sm text-text-muted">Copy cells from a spreadsheet or a web page, click a cell and paste.</p>
      <DocTable rows={rows} onChange={setRows} />
    </div>
  );
}

View code

Doc Table · Resizable

Installation

pnpm dlx velaui add @velaui/doc-table
pnpm dlx velaui add @velaui/doc-table

Code

doc-table-resizable.tsx
import * as React from "react";

import { DocTable } from "@/components/ui/doc-table";

export default function Example() {
  const [rows, setRows] = React.useState([
    ["Task", "Owner", "Team", "Status", "Due"],
    ["Freeze the schema", "Linh", "Platform", "Done", "Oct 02"],
    ["Dry migration", "Minh", "Platform", "Done", "Oct 04"],
    ["Backfill invoices", "Hoa", "Billing", "Done", "Oct 07"],
    ["Rotate API keys", "Nam", "Security", "Done", "Oct 08"],
    ["Cut over EU region", "Linh", "Platform", "In progress", "Oct 11"],
    ["Update pricing page", "Mai", "Growth", "In progress", "Oct 11"],
    ["Finance sign-off", "Hoa", "Billing", "In progress", "Oct 14"],
    ["Load test checkout", "Duc", "Payments", "In progress", "Oct 15"],
    ["Migrate webhooks", "Nam", "Payments", "Blocked", "Oct 16"],
    ["Customer email", "Mai", "Growth", "Blocked", "Oct 17"],
    ["Train support team", "Thu", "Support", "To do", "Oct 18"],
    ["Update status page", "Duc", "Platform", "To do", "Oct 21"],
    ["Retire old endpoints", "Minh", "Platform", "To do", "Oct 25"],
    ["Post-launch review", "Thu", "Support", "To do", "Oct 30"],
  ]);
  const [widths, setWidths] = React.useState<number[]>();
  return (
    <div className="w-full max-w-3xl space-y-2 p-6">
      <p className="text-sm text-text-muted">
        Drag a column border, or focus it and press the arrow keys. Drag the handles to move rows and columns.
      </p>
      <DocTable rows={rows} onChange={setRows} widths={widths} onWidthsChange={setWidths} />
      <p className="font-mono text-xs text-text-muted">{widths ? widths.join(", ") + " px" : "sized by content"}</p>
    </div>
  );
}

View code

Doc Table · Full width

Installation

pnpm dlx velaui add @velaui/doc-table
pnpm dlx velaui add @velaui/doc-table

Code

doc-table-full-width.tsx
import * as React from "react";

import { DocTable } from "@/components/ui/doc-table";

export default function Example() {
  const [rows, setRows] = React.useState([
    ["Region", "Q1", "Q2", "Q3", "Q4"],
    ["Hanoi", "1.2M", "1.4M", "1.6M", "1.9M"],
    ["Hai Phong", "0.5M", "0.5M", "0.6M", "0.7M"],
    ["Da Nang", "0.4M", "0.5M", "0.5M", "0.6M"],
    ["Hue", "0.2M", "0.2M", "0.3M", "0.3M"],
    ["Nha Trang", "0.3M", "0.3M", "0.4M", "0.5M"],
    ["Da Lat", "0.2M", "0.3M", "0.3M", "0.4M"],
    ["Ho Chi Minh City", "2.1M", "2.4M", "2.9M", "3.4M"],
    ["Bien Hoa", "0.4M", "0.4M", "0.5M", "0.6M"],
    ["Can Tho", "0.3M", "0.4M", "0.4M", "0.5M"],
    ["Vung Tau", "0.2M", "0.3M", "0.3M", "0.4M"],
    ["Quy Nhon", "0.1M", "0.2M", "0.2M", "0.2M"],
    ["Vinh", "0.2M", "0.2M", "0.2M", "0.3M"],
    ["Buon Ma Thuot", "0.1M", "0.1M", "0.2M", "0.2M"],
    ["Thai Nguyen", "0.2M", "0.2M", "0.3M", "0.3M"],
  ]);
  return (
    <div className="w-full max-w-3xl space-y-2 p-6">
      <p className="text-sm text-text-muted">Press Full width to stretch the table to the edges of the window.</p>
      <DocTable rows={rows} onChange={setRows} />
    </div>
  );
}

View code

Doc Table · Read only

Installation

pnpm dlx velaui add @velaui/doc-table
pnpm dlx velaui add @velaui/doc-table

Code

doc-table-read-only.tsx
import { DocTable } from "@/components/ui/doc-table";

export default function Example() {
  return (
    <div className="w-full max-w-3xl p-6">
      <DocTable
        readOnly
        rows={[
          ["Task", "Owner", "Team", "Status", "Due"],
          ["Freeze the schema", "Linh", "Platform", "Done", "Oct 02"],
          ["Dry migration", "Minh", "Platform", "Done", "Oct 04"],
          ["Backfill invoices", "Hoa", "Billing", "Done", "Oct 07"],
          ["Rotate API keys", "Nam", "Security", "Done", "Oct 08"],
          ["Cut over EU region", "Linh", "Platform", "In progress", "Oct 11"],
          ["Update pricing page", "Mai", "Growth", "In progress", "Oct 11"],
          ["Finance sign-off", "Hoa", "Billing", "In progress", "Oct 14"],
          ["Load test checkout", "Duc", "Payments", "In progress", "Oct 15"],
          ["Migrate webhooks", "Nam", "Payments", "Blocked", "Oct 16"],
          ["Customer email", "Mai", "Growth", "Blocked", "Oct 17"],
          ["Train support team", "Thu", "Support", "To do", "Oct 18"],
          ["Update status page", "Duc", "Platform", "To do", "Oct 21"],
          ["Retire old endpoints", "Minh", "Platform", "To do", "Oct 25"],
          ["Post-launch review", "Thu", "Support", "To do", "Oct 30"],
        ]}
      />
    </div>
  );
}

Usage

import * as React from "react";

import { DocTable } from "@/components/ui/doc-table";

export default function Example() {
  const [rows, setRows] = React.useState([
    ["Task", "Owner", "Team", "Status", "Due"],
    ["Freeze the schema", "Linh", "Platform", "Done", "Oct 02"],
    ["Dry migration", "Minh", "Platform", "Done", "Oct 04"],
    ["Backfill invoices", "Hoa", "Billing", "Done", "Oct 07"],
    ["Rotate API keys", "Nam", "Security", "Done", "Oct 08"],
    ["Cut over EU region", "Linh", "Platform", "In progress", "Oct 11"],
    ["Update pricing page", "Mai", "Growth", "In progress", "Oct 11"],
    ["Finance sign-off", "Hoa", "Billing", "In progress", "Oct 14"],
    ["Load test checkout", "Duc", "Payments", "In progress", "Oct 15"],
    ["Migrate webhooks", "Nam", "Payments", "Blocked", "Oct 16"],
    ["Customer email", "Mai", "Growth", "Blocked", "Oct 17"],
    ["Train support team", "Thu", "Support", "To do", "Oct 18"],
    ["Update status page", "Duc", "Platform", "To do", "Oct 21"],
    ["Retire old endpoints", "Minh", "Platform", "To do", "Oct 25"],
    ["Post-launch review", "Thu", "Support", "To do", "Oct 30"],
  ]);
  return (
    <div className="w-full max-w-3xl p-6">
      <DocTable rows={rows} onChange={setRows} />
    </div>
  );
}

Props

PropTypeDefault
classNamestring—
fullWidthboolean—
headerbooleantrue
onChange(rows: string[][]) => void—
onFullWidthChange(fullWidth: boolean) => void—
onWidthsChange(widths: number[]) => void—
readOnlyboolean—
rowsrequiredstring[][]—
widthsnumber[]—

Takes these and every prop of DocTableProps.

Dependencies

npm
none besides React
Installs with it
Dropdown Menu5 icons

Source

components/ui/doc-table.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";

import { DotHorizontal } from "@/components/icons/dot-horizontal";
import { DotVertical } from "@/components/icons/dot-vertical";
import { Plus01 } from "@/components/icons/plus-01";
import { SortHorizontal } from "@/components/icons/sort-horizontal";
import { SortVertical01 } from "@/components/icons/sort-vertical-01";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";

/**
 * The table on the clipboard, or null when it holds none: an HTML `<table>` (websites, Docs, Sheets, Excel)
 * or tab-separated text (spreadsheets). A single value is not a table, so a normal paste handles it.
 */
export function parseClipboardTable(data: DataTransfer): string[][] | null {
  const html = data.getData("text/html");
  const table = html && new DOMParser().parseFromString(html, "text/html").querySelector("table");
  const rows = table
    ? [...table.rows].map(tr => [...tr.cells].map(td => (td.textContent ?? "").replace(/\s+/g, " ").trim()))
    : data
        .getData("text/plain")
        .replace(/\r\n?/g, "\n")
        .replace(/\n+$/, "")
        .split("\n")
        .map(line => line.split("\t"));
  const width = Math.max(0, ...rows.map(r => r.length));
  if (rows.length * width < 2 || (!table && width < 2)) return null;
  return rows.map(r => [...r, ...Array<string>(width - r.length).fill("")]);
}

/** RFC 4180 CSV; the BOM keeps Excel from misreading UTF-8 (Vietnamese) text. */
export function tableToCsv(rows: string[][]) {
  const cell = (v: string) => (/[",\r\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
  return "\uFEFF" + rows.map(r => r.map(cell).join(",")).join("\r\n");
}

const DEFAULT_WIDTH = 160;
const MIN_WIDTH = 48;
/** Width of the column that holds the row handles, in px. */
const GUTTER = 48;

const move = <T,>(items: T[], from: number, to: number) => {
  const next = [...items];
  next.splice(to, 0, ...next.splice(from, 1));
  return next;
};

/** Tab-separated text, which spreadsheets paste as cells; a cell holding a tab, quote or newline is quoted. */
export function tableToTsv(rows: string[][]) {
  const cell = (v: string) => (/[\t"\r\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
  return rows.map(r => r.map(cell).join("\t")).join("\n");
}

/** An HTML `<table>`, which documents and other editors paste as a table. */
export function tableToHtml(rows: string[][]) {
  const table = document.createElement("table");
  for (const row of rows) {
    const tr = table.insertRow();
    for (const value of row) tr.insertCell().textContent = value;
  }
  return table.outerHTML;
}

export interface DocTableProps {
  rows: string[][];
  /** Column widths in px. Omit for columns sized by their content until one is dragged; the table then keeps its own widths. */
  widths?: number[];
  onWidthsChange?: (widths: number[]) => void;
  /** Break out of the surrounding column to the edges of the window. Omit to let the table keep its own setting. */
  fullWidth?: boolean;
  onFullWidthChange?: (fullWidth: boolean) => void;
  readOnly?: boolean;
  onChange?: (rows: string[][]) => void;
  /** Bold the first row. */
  header?: boolean;
  className?: string;
}

const btn =
  "rounded px-2 py-0.5 text-xs text-text-muted hover:bg-bg-surface-2 disabled:opacity-40 pointer-coarse:min-h-11 pointer-coarse:min-w-11";

interface GripItem {
  label: string;
  onSelect: () => void;
  disabled?: boolean;
  destructive?: boolean;
}

/** The dots at the head of a row or column: opens that row's or column's menu. */
function Grip({
  label,
  vertical,
  active,
  items,
}: {
  label: string;
  vertical?: boolean;
  active: boolean;
  items: GripItem[];
}) {
  const Icon = vertical ? DotVertical : DotHorizontal;
  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        aria-label={label}
        tabIndex={active ? 0 : -1}
        className="flex size-5 items-center justify-center rounded text-text-muted hover:bg-bg-surface-2 aria-expanded:bg-bg-surface-2 pointer-coarse:size-11"
      >
        <Icon size={16} />
      </DropdownMenuTrigger>
      <DropdownMenuContent align="start" aria-label={label}>
        {items.map(({ label: text, onSelect, disabled, destructive }, i) => (
          <React.Fragment key={text}>
            {destructive && i > 0 && <DropdownMenuSeparator />}
            <DropdownMenuItem disabled={disabled} variant={destructive ? "destructive" : "default"} onClick={onSelect}>
              {text}
            </DropdownMenuItem>
          </React.Fragment>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

/** Drag a row or column by this; the table works out the drop position from the pointer. Menus offer the keyboard way. */
function DragHandle({
  label,
  vertical,
  onStart,
  onMove,
  onEnd,
}: {
  label: string;
  vertical?: boolean;
  onStart: () => void;
  onMove: (x: number, y: number) => void;
  onEnd: (commit: boolean) => void;
}) {
  const active = React.useRef(false);
  const Icon = vertical ? SortVertical01 : SortHorizontal;
  return (
    <button
      type="button"
      tabIndex={-1}
      aria-label={label}
      className="flex size-5 cursor-grab touch-none items-center justify-center rounded text-text-muted hover:bg-bg-surface-2 active:cursor-grabbing pointer-coarse:size-6"
      onPointerDown={e => {
        e.preventDefault();
        e.currentTarget.setPointerCapture(e.pointerId);
        active.current = true;
        onStart();
      }}
      onPointerMove={e => active.current && onMove(e.clientX, e.clientY)}
      onPointerUp={() => active.current && ((active.current = false), onEnd(true))}
      onPointerCancel={() => active.current && ((active.current = false), onEnd(false))}
    >
      <Icon size={16} />
    </button>
  );
}

/** The plus that shows on a row or column boundary under the pointer. The grip menu is the keyboard and touch way in. */
function InsertHandle({ label, className, onClick }: { label: string; className: string; onClick: () => void }) {
  return (
    <button
      type="button"
      tabIndex={-1}
      aria-label={label}
      className={cn(
        "absolute z-10 flex size-4 items-center justify-center rounded-full bg-primary-default text-text-inverse opacity-0 group-hover/grip:opacity-100 pointer-coarse:hidden",
        className,
      )}
      onClick={onClick}
    >
      <Plus01 size={12} strokeWidth={2} />
    </button>
  );
}

/**
 * The drag strip on a column's right border. It is a vertical separator: pointer drags it, and the one on the
 * first row also takes the arrow keys.
 */
function ResizeHandle({
  label,
  focusable,
  onStart,
  onDrag,
}: {
  label: string;
  focusable?: boolean;
  onStart: () => void;
  onDrag: (delta: number) => void;
}) {
  const from = React.useRef<number | null>(null);
  return (
    <div
      role="separator"
      aria-orientation="vertical"
      aria-label={label}
      tabIndex={focusable ? 0 : -1}
      className="absolute inset-y-0 -right-1 z-10 w-2 cursor-col-resize touch-none hover:bg-primary-default/40 focus-visible:bg-primary-default/40 focus-visible:outline-none pointer-coarse:w-4"
      onPointerDown={e => {
        e.currentTarget.setPointerCapture(e.pointerId);
        from.current = e.clientX;
        onStart();
      }}
      onPointerMove={e => from.current !== null && onDrag(e.clientX - from.current)}
      onPointerUp={() => (from.current = null)}
      onPointerCancel={() => (from.current = null)}
      onKeyDown={e => {
        const step = e.key === "ArrowRight" ? 16 : e.key === "ArrowLeft" ? -16 : 0;
        if (!step) return;
        e.preventDefault();
        onStart();
        onDrag(step);
      }}
    />
  );
}

/** An editable grid with a header row. Row and column menus add, insert and delete; paste and CSV export built in. */
function DocTable({
  rows,
  widths,
  onWidthsChange,
  fullWidth,
  onFullWidthChange,
  readOnly,
  onChange,
  header = true,
  className,
}: DocTableProps) {
  const cols = rows[0]?.length ?? 0;
  const save = (next: string[][]) => onChange?.(next);
  const blank = () => Array<string>(cols).fill("");
  const insertRow = (at: number) => save([...rows.slice(0, at), blank(), ...rows.slice(at)]);
  const [ownFull, setOwnFull] = React.useState(false);
  const full = fullWidth ?? ownFull;
  // Full width pulls the table's left edge to the window's and makes it as wide as the window (minus the scrollbar).
  const outer = React.useRef<HTMLDivElement>(null);
  const [bleed, setBleed] = React.useState<{ left: number; width: number }>();
  React.useLayoutEffect(() => {
    if (!full) return setBleed(undefined);
    const measure = () =>
      setBleed({
        left: -(outer.current?.getBoundingClientRect().left ?? 0),
        width: document.documentElement.clientWidth,
      });
    measure();
    window.addEventListener("resize", measure);
    return () => window.removeEventListener("resize", measure);
  }, [full]);
  const [ownWidths, setOwnWidths] = React.useState<number[]>();
  const sized = widths ?? ownWidths;
  const widthOf = sized && Array.from({ length: cols }, (_, c) => sized[c] ?? DEFAULT_WIDTH);
  const setWidths = (next: number[]) => {
    setOwnWidths(next);
    onWidthsChange?.(next);
  };
  const [drag, setDrag] = React.useState<{ axis: "row" | "col"; from: number; to: number } | null>(null);
  const reorder = (axis: "row" | "col", from: number, to: number) => {
    if (axis === "row") return save(move(rows, from, to));
    save(rows.map(r => move(r, from, to)));
    if (widthOf) setWidths(move(widthOf, from, to));
  };
  /** The row or column under the pointer; past either end it is the first or last. */
  const targetAt = (axis: "row" | "col", x: number, y: number) => {
    const boxes =
      axis === "row"
        ? [...(table.current?.tBodies[0]?.rows ?? [])]
        : [...(table.current?.tHead?.rows[0]?.cells ?? [])].slice(1);
    const i = boxes.findIndex(el => {
      const box = el.getBoundingClientRect();
      return axis === "row" ? y < box.bottom : x < box.right;
    });
    return i < 0 ? boxes.length - 1 : i;
  };
  const dragging = (axis: "row" | "col", from: number) => ({
    onStart: () => setDrag({ axis, from, to: from }),
    onMove: (x: number, y: number) => setDrag(d => d && { ...d, to: targetAt(d.axis, x, y) }),
    onEnd: (commit: boolean) => {
      if (commit && drag && drag.to !== drag.from) reorder(drag.axis, drag.from, drag.to);
      setDrag(null);
    },
  });
  /** The dragged row or column fades; the one it would land on gets a line on the side it would land. */
  const mark = (r: number, c: number) => {
    if (!drag) return "";
    const [at, to, from] = drag.axis === "row" ? [r, drag.to, drag.from] : [c, drag.to, drag.from];
    const after = to > from;
    const line =
      at !== to || to === from
        ? ""
        : drag.axis === "row"
          ? after
            ? "shadow-[inset_0_-2px_0_var(--primary-default)]"
            : "shadow-[inset_0_2px_0_var(--primary-default)]"
          : after
            ? "shadow-[inset_-2px_0_0_var(--primary-default)]"
            : "shadow-[inset_2px_0_0_var(--primary-default)]";
    return cn(at === from && "opacity-50", line);
  };
  const startWidths = React.useRef<number[]>([]);
  // A drag changes one column from the widths it started with; the others keep what they measured then.
  const startResize = () => {
    startWidths.current =
      widthOf ?? Array.from({ length: cols }, (_, c) => cell(0, c)?.parentElement?.offsetWidth ?? DEFAULT_WIDTH);
  };
  const resize = (c: number, delta: number) =>
    setWidths(startWidths.current.map((w, i) => (i === c ? Math.max(MIN_WIDTH, w + delta) : w)));
  const insertCol = (at: number) => {
    save(rows.map(r => [...r.slice(0, at), "", ...r.slice(at)]));
    if (widthOf) setWidths([...widthOf.slice(0, at), DEFAULT_WIDTH, ...widthOf.slice(at)]);
  };
  const removeRow = (at: number) => rows.length > 1 && save(rows.filter((_, i) => i !== at));
  const removeCol = (at: number) => {
    if (cols < 2) return;
    save(rows.map(r => r.filter((_, i) => i !== at)));
    if (widthOf) setWidths(widthOf.filter((_, i) => i !== at));
  };

  const table = React.useRef<HTMLTableElement>(null);
  const cell = (r: number, c: number) =>
    table.current?.querySelector<HTMLInputElement>(`[data-r="${r}"][data-c="${c}"]`);
  const focusAt = React.useRef<[number, number] | null>(null);
  // After a shortcut adds or removes a row, put the caret back in the same column of the row it points at.
  React.useEffect(() => {
    if (!focusAt.current) return;
    cell(...focusAt.current)?.focus();
    focusAt.current = null;
  });
  const [copied, setCopied] = React.useState<"copied" | "failed" | null>(null);
  React.useEffect(() => {
    if (!copied) return;
    const t = setTimeout(() => setCopied(null), 1500);
    return () => clearTimeout(t);
  }, [copied]);
  /** Puts the table on the clipboard as HTML and tab-separated text, so it pastes as cells in a sheet or a table in a doc. */
  const copy = async () => {
    try {
      if (typeof ClipboardItem === "undefined") await navigator.clipboard.writeText(tableToTsv(rows));
      else {
        const blob = (type: string, data: string) => new Blob([data], { type });
        await navigator.clipboard.write([
          new ClipboardItem({
            "text/plain": blob("text/plain", tableToTsv(rows)),
            "text/html": blob("text/html", tableToHtml(rows)),
          }),
        ]);
      }
      setCopied("copied");
    } catch {
      setCopied("failed");
    }
  };
  const download = () => {
    const a = document.createElement("a");
    a.href = URL.createObjectURL(new Blob([tableToCsv(rows)], { type: "text/csv;charset=utf-8" }));
    a.download = "table.csv";
    a.click();
    URL.revokeObjectURL(a.href);
  };
  /** Pasted cells land from the focused cell down and right, and the grid grows to fit them. */
  const paste = (e: React.ClipboardEvent) => {
    const input = (e.target as HTMLElement).closest<HTMLInputElement>("input[data-r]");
    const pasted = !readOnly && input && parseClipboardTable(e.clipboardData);
    if (!pasted || !input) return;
    e.preventDefault();
    const r0 = Number(input.dataset.r);
    const c0 = Number(input.dataset.c);
    const height = Math.max(rows.length, r0 + pasted.length);
    const width = Math.max(cols, c0 + pasted[0].length);
    save(
      Array.from({ length: height }, (_, r) =>
        Array.from({ length: width }, (_, c) => pasted[r - r0]?.[c - c0] ?? rows[r]?.[c] ?? ""),
      ),
    );
  };
  const gutter = "p-0";
  // Handles show for the row and column under the pointer or holding focus (a tap focuses a cell, so touch gets them
  // too), and for the one being dragged. The rest stay hidden and out of the tab order; their space stays reserved.
  const [hover, setHover] = React.useState<{ r?: number; c?: number }>({});
  const [focus, setFocus] = React.useState<{ r?: number; c?: number }>({});
  const shown = (axis: "row" | "col", i: number) =>
    drag?.axis === axis ? drag.from === i : (axis === "row" ? (hover.r ?? focus.r) : (hover.c ?? focus.c)) === i;
  const reveal = (on: boolean) =>
    cn(
      "flex items-center justify-center gap-1 transition-opacity duration-100 focus-within:opacity-100 has-[[aria-expanded=true]]:opacity-100",
      !on && "opacity-0",
    );
  return (
    <div ref={outer} className={cn("my-2", className)}>
      <div
        className="overflow-x-auto pr-2 pb-2"
        style={full && bleed ? { marginLeft: bleed.left, width: bleed.width, paddingInline: 24 } : undefined}
        onPaste={paste}
        onPointerLeave={() => setHover({})}
        onBlur={e => e.currentTarget.contains(e.relatedTarget) || setFocus({})}
      >
        <table
          ref={table}
          className={cn("border-collapse text-sm", widthOf ? "table-fixed" : "w-full", full && "w-full")}
          style={
            widthOf && {
              [full ? "minWidth" : "width"]: widthOf.reduce((a, b) => a + b, 0) + (readOnly ? 0 : GUTTER),
            }
          }
        >
          {widthOf && (
            <colgroup>
              {!readOnly && <col style={{ width: GUTTER }} />}
              {widthOf.map((w, c) => (
                <col key={c} style={{ width: w }} />
              ))}
            </colgroup>
          )}
          {!readOnly && (
            <thead>
              <tr>
                <td className={gutter} style={{ width: GUTTER }} />
                {Array.from({ length: cols }, (_, c) => (
                  <td
                    key={c}
                    className="group/grip relative h-6 p-0 text-center"
                    onPointerEnter={() => setHover(h => ({ ...h, c }))}
                  >
                    <div className={reveal(shown("col", c))}>
                      <DragHandle label={`Drag column ${c + 1}`} {...dragging("col", c)} />
                      <Grip
                        active={shown("col", c)}
                        label={`Column ${c + 1} options`}
                        items={[
                          { label: "Add column before", onSelect: () => insertCol(c) },
                          { label: "Add column after", onSelect: () => insertCol(c + 1) },
                          {
                            label: "Move column left",
                            onSelect: () => reorder("col", c, c - 1),
                            disabled: c === 0,
                          },
                          {
                            label: "Move column right",
                            onSelect: () => reorder("col", c, c + 1),
                            disabled: c === cols - 1,
                          },
                          {
                            label: "Delete column",
                            onSelect: () => removeCol(c),
                            disabled: cols < 2,
                            destructive: true,
                          },
                        ]}
                      />
                    </div>
                    <InsertHandle
                      label={`Insert column after column ${c + 1}`}
                      className="top-1/2 -right-2 -translate-y-1/2"
                      onClick={() => insertCol(c + 1)}
                    />
                  </td>
                ))}
              </tr>
            </thead>
          )}
          <tbody>
            {rows.map((row, r) => (
              <tr key={r}>
                {!readOnly && (
                  <td className={cn(gutter, "group/grip relative")} onPointerEnter={() => setHover(h => ({ ...h, r }))}>
                    <div className={reveal(shown("row", r))}>
                      <DragHandle vertical label={`Drag row ${r + 1}`} {...dragging("row", r)} />
                      <Grip
                        vertical
                        active={shown("row", r)}
                        label={`Row ${r + 1} options`}
                        items={[
                          { label: "Add row above", onSelect: () => insertRow(r) },
                          { label: "Add row below", onSelect: () => insertRow(r + 1) },
                          {
                            label: "Move row up",
                            onSelect: () => reorder("row", r, r - 1),
                            disabled: r === 0,
                          },
                          {
                            label: "Move row down",
                            onSelect: () => reorder("row", r, r + 1),
                            disabled: r === rows.length - 1,
                          },
                          {
                            label: "Delete row",
                            onSelect: () => removeRow(r),
                            disabled: rows.length < 2,
                            destructive: true,
                          },
                        ]}
                      />
                    </div>
                    <InsertHandle
                      label={`Insert row after row ${r + 1}`}
                      className="-bottom-2 left-1/2 -translate-x-1/2"
                      onClick={() => insertRow(r + 1)}
                    />
                  </td>
                )}
                {row.map((value, c) => {
                  const Cell = header && r === 0 ? "th" : "td";
                  return (
                    <Cell
                      key={c}
                      scope={Cell === "th" ? "col" : undefined}
                      className={cn("relative border border-border-default p-0 text-left font-normal", mark(r, c))}
                      onPointerEnter={e => e.pointerType !== "touch" && setHover({ r, c })}
                    >
                      <input
                        data-r={r}
                        data-c={c}
                        onFocus={() => setFocus({ r, c })}
                        className={cn(
                          "w-full bg-transparent px-2 py-1 outline-none",
                          header && r === 0 && "font-semibold",
                        )}
                        value={value}
                        readOnly={readOnly}
                        aria-label={`Row ${r + 1}, column ${c + 1}`}
                        onKeyDown={e => {
                          if (!readOnly && (e.metaKey || e.ctrlKey)) {
                            if (e.key === "Enter") {
                              e.preventDefault();
                              focusAt.current = [r + 1, c];
                              insertRow(r + 1);
                            } else if (e.key === "Backspace" && rows.length > 1) {
                              e.preventDefault();
                              focusAt.current = [Math.min(r, rows.length - 2), c];
                              removeRow(r);
                            }
                            return;
                          }
                          const to = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
                          const next = to && cell(r + to, c);
                          if (next) (e.preventDefault(), next.focus());
                        }}
                        onChange={e =>
                          save(rows.map((rr, i) => (i === r ? rr.map((v, j) => (j === c ? e.target.value : v)) : rr)))
                        }
                      />
                      {!readOnly && (
                        <ResizeHandle
                          label={`Resize column ${c + 1}`}
                          focusable={r === 0}
                          onStart={startResize}
                          onDrag={delta => resize(c, delta)}
                        />
                      )}
                    </Cell>
                  );
                })}
              </tr>
            ))}
          </tbody>
        </table>
        <div className="mt-1 flex justify-end gap-1">
          {!readOnly && (
            <button
              type="button"
              aria-pressed={full}
              className={cn(btn, "aria-pressed:bg-bg-surface-2 aria-pressed:text-text-strong")}
              onClick={() => (setOwnFull(!full), onFullWidthChange?.(!full))}
            >
              Full width
            </button>
          )}
          <button type="button" className={btn} onClick={copy}>
            {copied === "copied" ? "Copied" : copied === "failed" ? "Copy failed" : "Copy"}
          </button>
          <span role="status" className="sr-only">
            {copied === "copied" && "Table copied to clipboard"}
            {copied === "failed" && "Could not copy the table"}
          </span>
          <button type="button" className={btn} onClick={download}>
            Download CSV
          </button>
        </div>
      </div>
    </div>
  );
}

export { DocTable };