Spreadsheet Cell

A single spreadsheet cell. Renders the computed value when idle and an inline <input> when editing. The keyboard model matches Excel / Google Sheets — double-click, Enter, or F2 to edit, type a printable key to overwrite, Escape to cancel, Enter or blur to commit. Pair with your own dependency-graph evaluator to drive the display prop for formulas and the state attribute for cells whose values just changed downstream of an edit.

A1
10
A2
20
A3
30
Customize
Behaviour

Installation

npx shadcn@latest add https://craftbits.dev/r/spreadsheet-cell.json

Usage

import { SpreadsheetCell } from "@craft-bits/core";
 
<SpreadsheetCell
  cellId="A1"
  value="10"
  onChange={(next, meta) => updateCell("A1", next, meta.formula)}
/>

Formula cell with computed display — value is the raw formula text, display is the evaluation result:

<SpreadsheetCell
  cellId="A3"
  value="=A1+A2"
  display={30}
  onChange={(next) => updateCell("A3", next)}
/>

Drive selection / dependency highlights from your own algorithm reducer — the cell is pure render:

<SpreadsheetCell
  cellId={id}
  value={model[id].raw}
  display={evaluate(id, model)}
  state={
    id === cursor ? "highlight" : affected.has(id) ? "affected" : "default"
  }
  error={errors[id] ?? null}
  onChange={(next, meta) => dispatch({ type: "edit", id, next, meta })}
/>

Understanding the component

  1. Two render modes, one element. The root role="gridcell" div swaps between a <span> (idle) and an <input> (editing). tabIndex flips between 0 and -1 so Tab walks idle cells but never dumps focus on an input that has already taken the cursor.
  2. Three commit paths, one helper. Enter, blur, and (when controlled externally) a re-render with editing=false all funnel through one commit(next) call. The helper updates uncontrolled state if any and fires onChange with a cellId / raw / formula meta payload — consumers do not have to re-detect the leading =.
  3. Type-to-overwrite. When the cell is idle and the user presses a single printable key, that key is stashed in a ref and editing starts; the effect that runs on editing=true reads the ref and seeds the input. That is the Excel / Sheets convention — start typing and the prior value is replaced, no double-click required.
  4. Controlled / uncontrolled symmetry. value plus onChange is controlled; defaultValue alone is uncontrolled. The same applies to editing plus onEditStart and onCancel — pass editing to drive edit mode from outside (e.g. a cursor in a larger grid), or omit it and let the cell self-manage.
  5. State via data attributes. state, error, formula, and bold all surface as data-* attributes on the root. Tailwind variant selectors drive every visual treatment — no className toggle gymnastics, and consumers can target the same attributes with their own CSS without forking the component.

Props

PropTypeDefaultDescription
cellIdstringrequiredStable identifier (e.g. "A1"). Echoed in the accessible name and the onChange meta.
valuestringControlled raw text. Pair with onChange.
defaultValuestring""Uncontrolled initial raw text.
displayReactNodeWhat the cell renders when idle. For formulas, pass the evaluation result. Defaults to value when omitted.
errorstring | nullnullEvaluation error. When set, replaces display and adds data-error="true".
state"default" | "highlight" | "affected" | "dimmed""default"Semantic visual state.
boldbooleanfalseRender text bold.
editingbooleanControlled edit mode. Omit to let the cell self-manage.
onEditStart() => voidFired when the user enters edit mode.
onChange(next, meta) => voidFired on commit. meta is cellId / raw / formula.
onCancel() => voidFired on Escape.
widthstring'100%'CSS width.
heightstring'28px'CSS height.
aria-labelstringautoAccessible name. Defaults to a cellId + value summary.
classNamestringMerged onto the root <div>.

Accessibility

  • Root element is a role="gridcell" div so the cell drops cleanly into a role="grid" wrapper.
  • The accessible name defaults to a cellId + display summary (or an error summary when erroring, or cellId: empty). Override with aria-label if your grid already names cells globally.
  • Editing surfaces a real <input> so screen readers, IME composition, autofill, and OS spellcheck-disable all work natively. The input has its own aria-label of Edit <cellId>.
  • Idle cells are focusable (tabIndex=0); the editing input gets focus, and tabIndex on the wrapper flips to -1 so Tab does not hop back out mid-edit.
  • Keyboard: double-click, Enter, or F2 enter edit mode. Single printable keys enter edit mode with overwrite semantics. Enter commits, Escape cancels, blur commits.
  • data-state, data-formula, data-error, data-bold are styling hooks — consumers can re-skin with CSS alone, no className override required.

Credits

  • Extracted from: terminal-dreams (src/components/frontend-design/sdp-spreadsheet/ui/CellComponent.tsx). Stripped the TD CSS-module chrome and the four boolean flag tangle (isAffected, isHighlighted, isEditing, bold) in favour of a single state enum + a controlled editing prop, generalized the value/formula split into a value / display / error triple so the consumer's evaluator drives presentation, added controlled+uncontrolled editing, and pinned every state to a data-* attribute so consumers can re-theme without forking.