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-tablepnpm dlx velaui add @velaui/doc-tableyarn dlx shadcn@latest add @velaui/doc-tablebunx --bun shadcn@latest add @velaui/doc-tableveni add doc-tableInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/doc-tablepnpm dlx velaui add @velaui/doc-tableyarn dlx shadcn@latest add @velaui/doc-tablebunx --bun shadcn@latest add @velaui/doc-tableExamples
- Default
- Paste
- Resizable
- Full width
- Read only
Usage
Props
| Prop | Type | Default |
|---|---|---|
className | string | — |
fullWidth | boolean | — |
header | boolean | true |
onChange | (rows: string[][]) => void | — |
onFullWidthChange | (fullWidth: boolean) => void | — |
onWidthsChange | (widths: number[]) => void | — |
readOnly | boolean | — |
rowsrequired | string[][] | — |
widths | number[] | — |
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 };