Skip to content

Interaction and editing

Configure selection, editing, clipboard, validation, protection, search, merge, and fill behavior.
On this page

Installation

Sheetwrite handles selection, keyboard navigation, inline editing, the clipboard, the toolbar, merges, and drag-to-fill out of the box. The host element is made focusable (tabindex="0") so it receives keyboard events. Setting readOnly: true disables every mutating interaction below (editing, clearing, fill, paste, and restyling) while leaving navigation and selection intact.

Selection model

A Selection is one of five shapes:

Partial example
type Selection =
| { kind: "cell"; addr: CellAddress }
| { kind: "range"; range: Range }
| { kind: "row"; sheet: SheetId; row: number }
| { kind: "column"; sheet: SheetId; col: number }
| { kind: "multi"; ranges: Range[] };

Read or set it imperatively, and subscribe to changes:

Partial example
const sel = grid.getSelection();
grid.setSelection({ kind: "cell", addr: { sheet: "sheet1", row: 0, col: 0 } });
grid.setSelection(null); // clear
grid.on("selection", (e) => console.log(e.selection));

How selections are made with the mouse (primary button):

GestureResult
Click a cellSelect that cell.
Drag across cellsExtend to a range.
Shift-clickExtend the range from the current anchor.
Ctrl/Cmd-clickAdd a region — produces a multi selection.
Click a column letter (top header)Select that column (Shift extends; Ctrl/Cmd adds).

Row and full-column selections are part of the model. Column selection is wired to the top header; row selection is programmatic only — there is no row-header click gesture, so set it via setSelection({ kind: "row", sheet, row }).

Only the pointer that began a drag can change it. Switching sheets, changing sheet structure or the sorted/filtered view, or losing pointer capture ends the gesture; a later pointer release cannot apply it to a different sheet or view.

Resizing

Drag a column-header boundary or a row-header boundary to preview its size. The preview changes geometry only: the document and undo history remain intact until release. Release submits one undoable size change. Cancellation, capture loss, or a rejected change restores the document's size. Turning read-only on ends an active resize without committing.

Keyboard navigation

When the grid is focused and not editing:

KeyAction
Arrow keysMove the focus one cell.
Ctrl/Cmd + ArrowJump to the first/last row or column.
Page Up / Page DownMove by a viewport of rows.
Home / EndMove to the first / last column of the row.
Ctrl/Cmd + Home / EndMove to the sheet's first / last cell.
Shift + any of the aboveExtend the selection instead of moving.
Enter or F2Edit the focused cell (F2 selects the existing text).
Delete / BackspaceClear the selected cells.
A printable characterStart editing, replacing the cell's content.
Ctrl/Cmd + C / X / VCopy / cut / paste.
Ctrl/Cmd + ZUndo the last edit.
Ctrl/Cmd + Shift + Z (or Ctrl/Cmd + Y)Redo.
Ctrl/Cmd + FOpen the find bar (when config.find is not false).

Rebind an action

Use config.keyboard.bindings to replace the keys for one action. Omitted actions keep their defaults. Use null to remove an action's keys, or an array for multiple bindings. These bindings apply when the grid has focus and is not editing.

Write a key combination as a string or as an object:

  • A string lists modifiers and then one key: "Mod+Shift+K", "Ctrl+Alt+Delete". The modifiers are Mod, Ctrl, Meta, Alt and Shift, in any order.
  • An object names the key and its modifiers: { key: "k", mod: true, shift: true }. Use the object form for a key without modifiers, for example { key: "F2" }.

Mod shows as Command on macOS and as Control on other systems. It accepts either key, so the same shortcut works with a Mac keyboard and a PC keyboard. Ctrl and Meta are always exactly the Control and Command (or Windows) keys, so "Ctrl+J" is Control+J on a Mac too. Shift and Alt must match exactly.

Name the key as printed without Shift: "Mod+Shift+1" and "Mod+Shift+/" run when the user presses Shift with the 1 or / key, although the browser reports ! and ? for those presses. Letters and digits keep their meaning on other keyboard layouts.

Rebind copy and keep its menu label in sync
bindings: {
copy: "Mod+Shift+C",
find: { key: "f", mod: true },
replace: null,
},
},
{ action: "copy", label: "Copy value", shortcut: "copy" },
],
});

The Copy label is ⌘⇧C on macOS and Ctrl+Shift+C elsewhere. Its original Mod+C binding no longer runs Copy. Menu actions use their bound shortcut by default; shortcut: "copy" lets a custom item use the same label. Built-in toolbar titles and grid.getShortcutLabel("copy") use the same keymap.

Add a shortcut for your own command

Give a context-menu item a shortcut and an onClick. The grid then runs onClick when the grid has focus and the user presses that combination, with the focused cell as the cell argument. A combination with Mod, Ctrl or Meta also works while the inline editor is open. A hidden or disabled item does not run.

A custom command with its own shortcut
{
id: "open-record",
label: "Open record",
shortcut: "Mod+Shift+O",
onClick: (_grid, cell) => {
if (cell) console.log("open record for row", cell.row);
},
},
],
});

If two commands use the same combination, createGrid and setConfig throw a SheetwriteError with code shortcut-conflict, and setConfig keeps the previous configuration. A malformed combination throws code invalid-shortcut.

Use keyboard: false to handle all built-in keys in the host. The existing intercept function remains available: return true to consume a key or false to let the built-in handler continue. See KeyboardOptions.

Undo and redo

grid.undo() and grid.redo() (and Ctrl/Cmd + Z, Ctrl/Cmd + Shift + Z) step through the last 200 recorded transactions. Remote operations from a SyncCoordinator do not create undo entries.

Undo of a large clear restores every old cell. When the restore is larger than one transaction allows, the Grid sends one compressed restoreBlock operation. If that still needs more than one server version, it uses an atomic batch, which needs PersistenceAdapter.commitBatch. See Large undo and atomic batches.

If the restore is above the batch ceilings, cannot be split, or needs a commitBatch that the adapter does not have, grid.undo() changes nothing. The Grid emits mutation-rejected with a resource-limit issue and removes that entry from the history, so older edits can still be undone. Show the reason to the user:

Report an undo that is too large
grid.on("mutation-rejected", ({ issues }) => {
for (const issue of issues) {
if (issue.kind === "resource-limit") {
console.warn(`Not undone: ${issue.resource} ${issue.actual} is above ${issue.max}.`);
}
}
});

Inline editing

Editing happens in a single real <textarea> overlaid on the active cell. It is never recycled across cells, so IME composition and focus survive scrolling — composing CJK or other input methods is safe. Begin editing by double-clicking, pressing Enter or F2, or just typing.

Commit and navigation keys:

KeyResult
EnterCommit, move down.
TabCommit, move right.
Shift + TabCommit, move left.
Shift + EnterInsert a newline (does not commit).
EscapeCancel the edit.
Blur (click away)Commit in place.

While the IME is composing, keystrokes belong to the composition and are not treated as navigation. Editing emits edit-begin and edit-commit events.

beginEdit closes the previous editor, selects the requested cell, and emits selection when that changes the selection, then emits edit-begin before opening the editor. A synchronous handler can replace or cancel the request without the old editor reopening. Successful completion emits change, then edit-commit, then the navigation's selection event; rejected completion does not emit edit-commit or navigate. Event addresses remain view coordinates.

Stock, validation, and custom editors retain the underlying document cell while sorting or filtering changes its visible position. Completion writes that same cell, not whichever row now occupies the old position. If the cell leaves the view, or sheet structure changes, the editor is cancelled.

Clipboard interoperability

Copy and cut always produce injection-hardened TSV and, where the browser exposes ClipboardItem, also write sanitized text/html plus Sheetwrite's private application/x-sheetwrite+json payload. The private payload preserves formula source and cell styles for same-app round-trips. HTML contains only escaped table markup, plain cell text, safe data-sheetwrite-formula attributes, and a bounded style subset; no pasted HTML is inserted into the document.

Paste prefers the private Sheetwrite payload, then sanitized HTML tables, then plain TSV. HTML parsing uses an inert DOMParser document and accepts only cell text, safe formula attributes, and supported inline styles. External formulas containing control characters are treated as text. TSV handles quoted, multi-line fields, matching Excel and Google Sheets. External text beginning with =, +, -, @, tab, or CR is prefixed with a single quote, so it cannot become an executable formula.

paste() preserves supported formula/style data; pasteValues() intentionally writes resolved literals and drops formulas/styles. Cut clears its source only after the system clipboard accepts the payload. Paste is disabled when readOnly.

grid.actions.copy/cut/paste/pasteValues return Promise<ClipboardOutcome> — "done" | "unsupported" | "blocked" | "empty" — and never reject. A missing API resolves "unsupported"; permission or user-activation rejection resolves "blocked", so custom UI can surface the outcome without tripping global error monitoring.

Validation, protection, and notes

DataValidationRule covers list, numeric range, date range, text-length, and checkbox conditions. Rules carry a stable ID, range, reject / warn / allow policy, optional blank handling, and help text. List rules open a keyboard- operable listbox; checkbox rules open a real ARIA checkbox. Arrow keys and typeahead change a list choice, Space toggles a checkbox, Enter commits, Tab commits and moves, and Escape cancels. Every mutation ingress—including paste, fill, clear, and host transactions—passes through the same validation boundary.

ProtectedRange is serializable client policy metadata. Local writes are denied by default and can be allowed by a host ProtectionResolver; atomic mode rejects the whole transaction, while partial mode applies allowed operations and reports the rest. Remote operations bypass this UX policy. A server must enforce its own authorization.

Plain-text notes use setNote(addr, text) / getNote(addr). The focused cell's note is exposed as the ARIA description. Notes serialize, rebase with structural edits, participate in undo/redo, and round-trip through Sheetwrite XLSX metadata.

Toolbar actions

When you opt into the toolbar via config, each control acts on the current selection. The controls (and the config flag that gates each) are:

ControlFlag
Boldbold
Italicitalic
Align left / center / rightalign
Text colortextColor
Fill colorfillColor
Borderborder
Clear formattingclearFormat
Merge / unmergemerge
Sort ascending / descendingsort

See Configuration → GridConfig.

Context menu

GridConfig.contextMenu is true by default, false to disable, a static readonly item list, or a factory evaluated for each right-click context. Sheetwrite prevents the browser menu, hit-tests the pointer, and selects the resolved cell before evaluating item visibility/disabled predicates.

Rows may provide id, label, and shortcut; bind a built-in action or use onClick(grid, cell). visible and disabled accept booleans or predicates receiving the lean { cell, clientX, clientY } ContextMenuContext. Hidden separators are normalized and disabled rows cannot invoke actions.

The built-in action names are:

Clipboard/contentRowsColumnsView/export
cut, copy, paste, clearContentsinsertRowAbove, insertRowBelow, deleteRow, hideRow, showAllRows, autoFitRowinsertColumnLeft, insertColumnRight, deleteColumn, hideColumn, showAllColumns, autoFitColumnclearFilter, merge, unmerge, exportCsv, exportXlsx

The default list uses the clipboard/content, row, column, filter, and merge/unmerge actions; export actions are available for custom lists. XLSX still requires @sheetwrite/xlsx/register.

For fully host-owned menu rendering, disable the bundled menu and bind the host element's DOM contextmenu event. grid.getCellAtPoint(clientX, clientY) resolves the pointer without exposing renderer internals; the host then owns preventDefault, selection, positioning, dismissal, and invoking grid.actions.

See Configuration → Context-menu items for both models and Styling → Widget and context-menu styling for the bundled menu CSS hooks.

Temporary highlights

grid.highlightCells(ranges) draws an overlay, not a document edit. Undo does not clear it. Call grid.highlightCells(null) to clear all temporary highlights. Choose a distinct overlay color so users can tell it from selection and cell formatting. Use grid.styleRange when a fill must be saved and undoable.

The Vanilla workbench offers Clear highlight in its context menu. Right-click the highlighted cell to toggle it off, or right-click another cell and choose Clear highlight. Both actions write to the host event log.

Merged cells

Merging combines the selected range into one cell: the top-left value is shown and the covered cells are hidden, tracked in a per-sheet merge registry. Unmerge reverses it. Merges are not drawn while a sort or filter view is active (the display order no longer maps cleanly to the merged region); clear the view to see them again. See Data operations.

Drag-to-fill

A small fill handle sits at the bottom-right corner of the selection. Drag it down, up, left, or right — the dominant axis wins — to fill the source block across the new region. The source is tiled (repeated) to cover the target.

When the filled cells contain formulas, references are rewritten as they are copied: relative A1 parts shift by the fill offset and absolute parts (prefixed with $) stay fixed. For example, filling =A1 + $B$1 one column right yields =B1 + $B$1. See Formulas → Point mode and reference rewriting.

Drag-to-fill is unavailable while a sort/filter view is active (the handle is hidden) and when readOnly.

Touch

Grid input runs on Pointer Events, so mouse, touch, and pen share one code path. For pointerType: "touch" the policy is:

GestureBehavior
Tap on a cellselect the cell (same as a click)
Tap on a column headerselect the column
Drag starting on a plain cellnative scroll — the grid neither captures the pointer nor calls preventDefault
Drag starting on the fill handledrag-to-fill (pointer captured)
Drag starting on a row/column resize boundaryresize (pointer captured)
Drag starting on the border of a multi-cell selection (±6px band)extend the selection (pointer captured)
Double-tap on a cellbegin editing (the browser's synthesized dblclick)

Mouse and pen behave classically: any drag from a cell extends the selection, and hovering a resize boundary shows the resize cursor. A pointercancel (e.g. the browser reclaims the gesture) abandons the drag without committing. The scroller sets touch-action: pan-x pan-y, which keeps native panning but disables double-tap zoom so captured drags keep receiving pointermove. Long-press → context menu on touch is deferred; when added it must cancel on move to respect the native-scroll rule.

Documentation