@c-time/simple-ex-grid
v1.7.1
Published
Excel-like grid component for React with clipboard, touch support, and cell selection
Readme
@c-time/simple-ex-grid
Excel-like grid component for React. Touch-first design with clipboard support, drag selection, and three operating modes.
Install
npm install @c-time/simple-ex-grid
# or
pnpm add @c-time/simple-ex-gridPeer dependencies: React 19+
Quick Start
import { SimpleExGrid } from '@c-time/simple-ex-grid'
import type { ColumnDef } from '@c-time/simple-ex-grid'
interface Row {
id: number
name: string
price: number
}
const columns: ColumnDef<Row>[] = [
{ key: 'id', header: 'ID', width: 60, editable: false },
{ key: 'name', header: 'Name', width: 200 },
{ key: 'price', header: 'Price', width: 120, parser: (s) => Number(s) || 0 },
]
function App() {
const [rows, setRows] = useState<Row[]>([
{ id: 1, name: 'Item A', price: 100 },
{ id: 2, name: 'Item B', price: 200 },
])
return (
<SimpleExGrid
rows={rows}
columns={columns}
mode="edit"
onChange={setRows}
/>
)
}Modes
| Mode | Description |
|------|-------------|
| select (default) | Selection and copy/paste only. No cell editing. |
| edit | Re-tap active cell or press Enter/F2 to start editing. |
| readonly | Selection and copy only. All mutations disabled. |
Props
Data
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| rows | Row[] | required | Row data array |
| columns | ColumnDef<Row>[] | required | Column definitions |
| onChange | (rows, changes) => void | - | Called when cell values change |
Mode & Display
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| mode | 'select' \| 'edit' \| 'readonly' | 'select' | Operating mode |
| showRowGripHeader | boolean | true | Show row grip headers (left) |
| showColumnGripHeader | boolean | false | Show column grip headers (top) |
| rowHeight | 'single-line' \| 'fit-content' | 'single-line' | single-line: truncate with ellipsis. fit-content: wrap text |
| scale | number | 1 | Rendering scale of the cells and headers. See Display Size |
Display Size
scale resizes everything that belongs to the cells together: text, padding, row height, the grip headers and drag handles, badges, and px column widths. 1 is the default size. The grid's own box keeps the size its parent gives it, so more or fewer cells fit in it. Scrollbars, the context menu, pagination and tooltips stay as they are. Values that are not a positive number fall back to 1.
The grid has no zoom UI of its own. A slider or a responsive rule lives in the app and passes a number in:
const [scale, setScale] = useState(1)
<input type="range" min={0.6} max={2} step={0.05} value={scale}
onChange={(e) => setScale(Number(e.target.value))} />
<SimpleExGrid rows={rows} columns={columns} scale={scale} />
// or, e.g., bigger cells on touch screens
<SimpleExGrid rows={rows} columns={columns} scale={isTouchScreen ? 1.4 : 1} />px column widths are preferred widths, at any scale: when the grid is narrower than the columns add up to, they shrink to fit (down to their text) before a horizontal scrollbar appears.
Column Definition
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| key | keyof Row & string | required | Row property key |
| header | string | required | Header display text |
| width | number \| '${n}%' \| 'fit-content' | 120 | Column width. Fixed px, percentage, or auto |
| editable | boolean | true | Set false to make column read-only |
| formatter | (value, row) => string | - | Display formatter |
| parser | (input) => unknown | - | Input parser (string to value) |
| validator | (value) => string \| null | - | Validation (return error message or null) |
| sortMark | 'asc' \| 'desc' \| null | null | Display sort indicator (▲/▼) in header. Display only — sorting logic is the caller's responsibility. |
| align | 'left' \| 'center' \| 'right' | - | Default text alignment for the column |
| headerAlign | 'left' \| 'center' \| 'right' | - | Header text alignment (overrides align) |
| color | string | - | Default text color for the column (any CSS color) |
| fontStyle | 'normal' \| 'italic' | - | Default font style for the column |
| fontWeight | 'normal' \| 'bold' | - | Default font weight for the column |
| hidden | boolean | false | Hide column visually. Hidden columns are still included in clipboard operations. |
| render | (value, row, rowIndex) => ReactNode | - | Custom cell renderer. Returned node replaces default text display. Display only — use editor to customise editing. |
| editor | (props: CellEditorProps<Row>) => ReactNode | - | Custom in-cell editor. Falls back to a single-line text input when omitted. |
Pagination
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| pagination | boolean | false | Enable pagination UI |
| pageSize | number | 20 | Rows per page |
| page | number | - | Controlled current page (0-indexed) |
| onPageChange | (page) => void | - | Page change callback |
| onPageSizeChange | (pageSize) => void | - | Page size change callback |
Selection
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| selection | CellRange \| null | - | Controlled selection range |
| onSelectionChange | (range) => void | - | Selection change callback |
| onActiveCellChange | (row, col) => void | - | Active cell move callback |
Clipboard
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| clipboard.enabled | boolean | true | Enable clipboard operations |
| clipboard.format | 'tsv' \| 'html' \| 'both' | 'both' | Clipboard data format |
Keyboard shortcuts: Ctrl+C copy, Ctrl+X cut, Ctrl+V insert paste (add rows), Ctrl+Shift+V overwrite paste.
Cell Layout
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| colSpan | (row, col) => number | - | Return column span for a cell. Spanned cells are merged visually. |
| cellAlign | (row, col) => 'left' \| 'center' \| 'right' \| undefined | - | Per-cell text alignment override (takes priority over column align). |
| cellColor | (row, col) => string \| undefined | - | Per-cell text color override (takes priority over column color). |
| cellFontStyle | (row, col) => 'normal' \| 'italic' \| undefined | - | Per-cell font style override (takes priority over column fontStyle). |
| cellFontWeight | (row, col) => 'normal' \| 'bold' \| undefined | - | Per-cell font weight override (takes priority over column fontWeight). |
Cell Badge
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| cellBadge | (row, col) => CellBadge \| undefined | - | Show a colored dot on the cell's top-right corner. Hover/tap to display a tooltip. |
CellBadge has two properties: type ('success' / 'warning' / 'error') and message (tooltip text).
| Type | Color | Use case |
|------|-------|----------|
| success | Green (#27ae60) | Approved, valid, complete |
| warning | Orange (#f39c12) | Caution, approaching limit |
| error | Red (#e74c3c) | Validation error, over limit |
<SimpleExGrid
rows={rows}
columns={columns}
cellBadge={(row, col) => {
if (col === 2 && rows[row].price >= 10000)
return { type: 'success', message: '承認済み' }
if (col === 3 && rows[row].qty >= 200)
return { type: 'error', message: `上限超過: ${rows[row].qty}` }
}}
/>Badge values are display-only and not included in clipboard operations.
Row Mutation
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| allowRowAdd | boolean | false | Allow adding rows (paste auto-extend, insert paste) |
| allowRowDelete | boolean | false | Allow deleting rows (context menu) |
| onRowsAdd | (startRow, count) => void | - | Fires after rows are added |
| onRowsDelete | (startRow, count) => void | - | Fires after rows are deleted |
Row Reordering
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| allowRowReorder | boolean | false | Add "行を上に移動" / "行を下に移動" (move row up / down) to the context menu |
| rowReorderMode | boolean | false | Drag reorder mode. Row grip headers show a handle, and dragging it moves rows instead of selecting them. The row grip header is shown while this is on, even with showRowGripHeader={false} |
Both move the selected rows as one block, and the selection follows them. A move calls onChange with the reordered rows and an empty changes array, since no cell value changed. One move is one undo step. Neither works in readonly mode.
rowReorderMode is a switch for the app to flip, like mode — the grid has no built-in toggle:
const [reordering, setReordering] = useState(false)
<button onClick={() => setReordering((v) => !v)}>並び順を編集</button>
<SimpleExGrid rows={rows} columns={columns} onChange={setRows} allowRowReorder rowReorderMode={reordering} />Undo / Redo
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| maxHistory | number | - | Maximum number of history entries to keep |
| shouldRecordHistory | (changes, rowEvent?) => boolean | - | Filter which mutations are recorded. rowEvent is { kind, startRow, count } for row add/delete. |
| historyApiRef | React.MutableRefObject<HistoryApi<Row>> | - | Ref to access undo/redo API externally |
HistoryApi<Row> exposes undo(), redo(), canUndo, canRedo, and pushHistory(before, after).
Keyboard shortcuts: Ctrl+Z undo, Ctrl+Y / Ctrl+Shift+Z redo.
Context Menu
| Prop | Type | Description |
|------|------|-------------|
| contextMenuItems | (defaultItems, context) => ContextMenuItem[] | Customize right-click menu items. Receives built-in items and context info, returns the final menu items. |
ContextMenuContext provides cell ({ row, col } | null), selection (CellRange | null), and mode.
<SimpleExGrid
rows={rows}
columns={columns}
contextMenuItems={(defaultItems, { cell }) => [
...defaultItems,
{ type: 'separator' },
{ label: '行を複製', disabled: !cell, onClick: () => duplicateRow(cell!.row) },
]}
/>Editing Lifecycle
| Prop | Type | Description |
|------|------|-------------|
| onEditStart | (row, col) => boolean \| void | Fires when editing begins. Return false to cancel. |
| onEditCommit | (row, col, before, after) => boolean \| void | Fires on edit confirm. Return false to prevent write. |
| onEditCancel | (row, col) => void | Fires when editing is cancelled (Escape). |
Clipboard Lifecycle
| Prop | Type | Description |
|------|------|-------------|
| onBeforeCopy | (context: BeforeCopyContext) => string[][] \| false \| void | Fires before copy/cut. Return false to cancel, string[][] to replace data, or void to proceed. |
| onBeforePaste | (context: BeforePasteContext) => string[][] \| false \| void | Fires before paste. Return false to cancel, string[][] to replace data, or void to proceed. |
BeforeCopyContext provides data (2D string array), source ({ row, col, endRow, endCol }), cut (boolean), and a mutable metadata (Record<string, unknown>) for attaching custom data to the clipboard.
BeforePasteContext provides data (2D string array), target ({ row, col }), insert (boolean — true for insert-paste, false for overwrite-paste), and metadata (ClipboardMeta | null — null when pasted from an external source).
Clipboard Metadata
When copying from the grid, a ClipboardMeta object is automatically written to the clipboard alongside the TSV/HTML data using a custom MIME type (web application/x-seg-clipboard). This metadata is available in onBeforePaste when pasting back into the same or another grid instance.
ClipboardMeta contains:
source— the original copy range ({ row, col, endRow, endCol })columnKeys— column keys of the copied rangecolSpans— per-row colspan values for owner columnscustom— user-defined metadata set viaonBeforeCopy'scontext.metadata
<SimpleExGrid
rows={rows}
columns={columns}
onBeforeCopy={(ctx) => {
// Attach custom metadata to the clipboard
ctx.metadata.sourceSheet = 'Sheet1'
ctx.metadata.copiedAt = Date.now()
}}
onBeforePaste={(ctx) => {
if (ctx.metadata) {
console.log('Pasted from grid:', ctx.metadata.custom.sourceSheet)
console.log('Column keys:', ctx.metadata.columnKeys)
} else {
console.log('Pasted from external source')
}
}}
/>Features
Selection
- Click to select, Shift+Click to extend range, drag for rectangle selection
- Arrow keys to navigate, Tab to move right (wraps), Shift+Tab to move left
Ctrl+Ato select all,Escapeto clear selection- Grip headers: click to select entire row/column, drag to extend
Editing (edit mode)
- Re-tap active cell, press Enter, F2, or type a character to begin editing
- Enter commits and moves down, Tab commits and moves right, Escape cancels
- Double-click also starts editing in any writable mode
- Shift+Click extends the range instead of editing, so multi-cell selection works the same as in select mode
- Dragging off the active cell extends the selection; releasing without leaving the cell starts editing, so a slightly shaky tap still opens the editor
- A press inside the open editor (right-click, or a tap on a touch screen) is left to the browser, so its own paste/select menu shows and the grid's menu never covers the editor
- A long press opens the context menu rather than starting an edit
Custom cell editors
ColumnDef.editor replaces the built-in text input for that column. It receives
the same editing plumbing the default editor uses, so Enter/Tab/Escape keep
working as long as onKeyDown is wired up:
const columns: ColumnDef<Row>[] = [
{
key: 'category',
header: 'Category',
editor: ({ value, onChange, onCommit, onKeyDown }) => (
<select
autoFocus
className="seg-cell-editor"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
onBlur={() => onCommit()}
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
),
},
]CellEditorProps<Row>:
| Prop | Type | Description |
|---|---|---|
| value | string | Current editing value. parser converts it on commit. |
| onChange | (value: string) => void | Update the value. Clears any validation error. |
| onCommit | () => boolean | Commit. Returns false when validator rejected the value. |
| onCancel | () => void | Discard and close. |
| onKeyDown | (e: React.KeyboardEvent) => void | Grid key handling (Enter/Tab/Escape). |
| error | string \| null | Message from the last rejected commit. |
| row | Row | The row being edited. |
| rowIndex / colIndex | number | Position of the cell. |
| column | ColumnDef<Row> | The column definition. |
The grid stops pointerdown from escaping the editor, so clicks inside a custom
editor do not commit it. Focus is not managed for you — set autoFocus (or focus
in an effect) on whatever element should receive it.
Validation
ColumnDef.validator runs on commit, after parser. Returning a message rejects
the commit: the editor stays open and is marked with .seg-cell-editor-invalid
(the message becomes its title), and Enter/Tab do not move the active cell.
Escape always cancels, so an invalid value can never trap the user in a cell.
{ key: 'quantity', header: 'Qty', parser: (s) => Number(s) || 0,
validator: (v) => (Number(v) < 1 ? 'Quantity must be at least 1' : null) }Clipboard
- Copy/cut/paste with Excel-compatible TSV + HTML table format
- Paste auto-extends rows when
allowRowAddis enabled - Insert paste (
Ctrl+V) inserts new rows at selection position - Overwrite paste (
Ctrl+Shift+V) overwrites existing cells Ctrl+Iinsert rows,Ctrl+Ddelete selected rows- Right-click context menu for all clipboard and row operations
- Undo/redo (
Ctrl+Z/Ctrl+Y) tracks all cell edits, paste, and row operations
Row Reordering
- Context menu "行を上に移動" / "行を下に移動" moves the selected rows one step (
allowRowReorder) - A selection that does not cover whole rows is widened to whole rows before moving
- With pagination, a row moved across a page boundary is followed to its new page
- Reorder mode (
rowReorderMode): drag a row grip handle to move rows- Grabbing a selected row drags every selected row; grabbing any other row drags just that one
- A line shows where the rows will land; dragging near the top/bottom edge auto-scrolls. When the grid runs off the screen, the screen edge counts, and the page scrolls once the grid itself cannot
Escapecancels the drag; a tap without dragging just selects the rowShift+click on a handle extends the row selection, so blocks can be built up without leaving the mode- Dragging across data cells still makes a rectangle selection
Touch Support
- Pointer Events API for unified mouse/touch/pen handling
setPointerCapturefor reliable touch drag selection- Grip headers opt out of browser panning (
touch-action: none), so a finger drag on them selects or reorders rows instead of scrolling. Swiping over cells scrolls the grid - In edit mode the active cell opts out too (until it is being edited), so the browser cannot take a tap on it for a scroll and swallow the edit
- Long-press opens context menu without losing selection (deferred selection pattern)
Layout
- Sticky grip headers (row headers stick left, column headers stick top)
- Custom overlay scrollbars (no layout shift from native scrollbar appearance)
- Works in fixed-size containers, percentage-based layouts, and unconstrained layouts
- Flex-based scroll structure handles both constrained and auto-sized parents
Development
pnpm install
pnpm dev # Storybook dev server
pnpm test # Vitest
pnpm typecheck # TypeScript
pnpm lint # ESLint
pnpm build # Vite library buildLicense
MIT
