@betteroffice/xlsx-react
v0.3.0
Published
Excel-faithful React XLSX spreadsheet editor and viewer — full OOXML fidelity, real-time human + AI agent collaboration.
Maintainers
Readme
@betteroffice/xlsx-react
React chrome for the BetterOffice XLSX editor — wraps
@betteroffice/xlsx in a
drop-in <XlsxEditor> component with the toolbar, selection, keyboard, and
clipboard wired up. Formula calculation and rendering run in the core's
Rust/WebAssembly engine; the grid is painted onto canvas.
bun add @betteroffice/xlsx-react @betteroffice/xlsx react react-domreact and react-dom (18 or 19) are peer dependencies.
Render a workbook
import { XlsxEditor } from "@betteroffice/xlsx-react";
<XlsxEditor file={bytes} fileName="report.xlsx" />;file is a Uint8Array of .xlsx bytes; omit it to render an empty frame.
Open and save
import { useState } from "react";
import { XlsxEditor } from "@betteroffice/xlsx-react";
export function App() {
const [file, setFile] = useState<Uint8Array>();
return (
<>
<input
type="file"
accept=".xlsx"
onChange={async (e) => {
const f = e.target.files?.[0];
if (f) setFile(new Uint8Array(await f.arrayBuffer()));
}}
/>
<XlsxEditor
file={file}
fileName="workbook.xlsx"
onSave={(bytes) => console.log(`saved ${bytes.length} bytes`)}
/>
</>
);
}Without onSave, the save button downloads the edited bytes.
Props: file, fileName, onSave, onChange, onReady (a handle for
host/agent-driven edits and the editor's commands), collaboration, i18n,
readOnly, toolbar, showToolbar, and className.
What works today
- Cell editing with formula recalculation of dependents on every edit
- Editing toolbar: number formats, fonts, colors, borders, alignment, merges
- Composable toolbar: built-in controls, the formula bar and host actions in your own order, bound to one command store
- Agent proposals: in-cell tracked-change ghosts plus an accept/reject panel
- Version-checked edit batches through
onReady, after pending input commits - Host-owned plugins with panels, overlays, lifecycle events, and explicitly granted commands and edit batches
- TSV clipboard copy/paste
- Accessible grid mirroring the painted canvas for screen readers
- Localized UI via the
i18nprop (@betteroffice/xlsx-i18n) - Real-time collaboration with people or agents; the workbook is a CRDT
- Live collaborator selections and presence chips, shown in each peer's color
Compose the toolbar
Every built-in control runs through the editor's command store. Replace the default toolbar with the parts you need, in your order, next to your own actions:
import {
EditorToolbar,
ToolbarButton,
ToolbarCommandButton,
ToolbarCommandSelect,
ToolbarGroup,
XlsxEditor,
} from "@betteroffice/xlsx-react";
<XlsxEditor
file={file}
toolbar={
<EditorToolbar mode="commands">
<EditorToolbar.Toolbar>
<ToolbarGroup label="Formatting">
<ToolbarCommandSelect id="numberFormat" />
<ToolbarCommandButton id="bold" />
</ToolbarGroup>
<ToolbarCommandButton id="undo" />
<ToolbarButton title="Share" onClick={share}>Share</ToolbarButton>
</EditorToolbar.Toolbar>
<EditorToolbar.FormulaBar />
</EditorToolbar>
}
/>;toolbaromitted renders the default toolbar (hidden whenreadOnly),nullrenders none, and supplied chrome renders in its place, also when read-only.showToolbar={false}hides the region either way.<EditorToolbar.Toolbar>children are the complete arrangement in command mode; without children it renders the default controls. PlaceEditorToolbar.FormulaBarinside the editor to show its name box and formula input.ToolbarCommandrenders a command's built-in control,ToolbarCommandButtona button (bindargs, such as{ value: "currency" }fornumberFormat), andToolbarCommandSelecta selector's picker.useXlsxCommand(id, args)anduseXlsxCommandState(id, args)bind custom controls.- Outside the editor, capture
api.commandsfromonReadyinto state and wrap the same parts in<XlsxCommandProvider commands={commands}>; passnulluntil the editor is ready. Its shortcuts then reach that editor. commands.getState(id, args?)returns serializable state (enabled,active,value, andoptionswhose entries carry their ownstate); a disabled command always carriesdisabledReasonwith acodeand a localizedmessage.commands.execute(id, args)runs in order with pastes, cell entries and chart moves accepted before it: it ends an IME composition, writes the text typed so far, then checks availability again. It resolves to{ ok: true, status }(executed,noop,opened,requested) or{ ok: false, failure }, for exampleinput-failed,document-replaced,target-changedwhen the selection moved while it waited,gesture-activeduring a chart drag,proposal-stale, orcommand-failedwhen the change could not be applied. A cell entry the workbook refuses stays open at its own cell for correction; until it is corrected or discarded with Escape, every command fails withinput-failed. Print waits until the canvas has painted the written text, and fails withrender-failedwhen it cannot.api.save()stays synchronous and never returns bytes without accepted input: it throwsXlsxSaveRefusedErrorwithcodeinput-pendingwhile entries or a paste still wait to be written, andinput-failedwhile a refused entry waits for correction.await api.commands.execute("save", null)waits for that input and then saves throughonSave. The API's version, read and batch methods reject withXlsxCommandAdmissionErrorinstead (see Edit batches); both errors are exported to branch oncode.- Every cell write goes through one queue.
api.selectCells,api.clearSelectionand switching sheets close the open entry and write it at once when nothing waits; otherwise it is queued behind the earlier input, so it may not have landed when they return. An open entry the workbook refuses stays open and they change nothing (selectCellsreturnsfalse); one it refused earlier reopens at its own cell once they land on its sheet. Commands run right after them, even in the same handler, act on the new selection and sheet. - The store enforces read-only mode, the selection a command needs and its
arguments for the editor's UI.
api.handlestays unrestricted host authority, and workbook protection is separate from the store. - Narrow toolbars move trailing groups into a keyboard-accessible More menu. Host
ToolbarButtons get an entry there; wrap other content inToolbarOverflow. A group holding content without an entry stays in the row, which scrolls horizontally when that content does not fit.
The prop-based EditorToolbar, Toolbar and useEditorToolbar API keeps
working unchanged in the default legacy mode and is deprecated for new
toolbars; in command mode useEditorToolbar returns a projection of the
command state whose callbacks run commands, and passing those props throws.
AI agents
onReady's handle is the WorkbookHandle. An agent stages edits with
propose() instead of applying them; the editor paints per-cell ghosts and a
review panel where the human accepts or rejects. The full proposal API lives in
@betteroffice/xlsx.
import { isProposalsAvailable } from "@betteroffice/xlsx";
import type { XlsxEditorApi } from "@betteroffice/xlsx-react";
<XlsxEditor
file={file}
onReady={({ handle, refreshProposals }: XlsxEditorApi) => {
if (!isProposalsAvailable()) return;
handle.propose("copilot", "add totals", [
{ sheet: 0, row: 9, col: 2, input: "=SUM(C1:C9)" },
]);
refreshProposals();
}}
/>;Edit batches
The onReady API's version, readCells, findText, validateEdits and
applyEdits run in the same queue as commands: cell and formula entries, chart
nudges, pastes and cuts accepted before them land first, and an IME composition
ends with its text written. When that cannot happen they reject with
XlsxCommandAdmissionError, whose code is input-failed while a refused entry
waits for correction, gesture-active during a chart drag, and
document-replaced when the workbook is replaced meanwhile. Refusals of the
batch itself stay data. applyEdits keeps the
caller's expectVersion, so input that lands first refuses the batch with
stale-version; read the version through the API to include it. While
readOnly, writes refuse with read-only. An applied batch repaints once and
calls onChange once.
import { XlsxCommandAdmissionError, XlsxEditor, type XlsxEditorApi } from "@betteroffice/xlsx-react";
<XlsxEditor
file={file}
onReady={(api: XlsxEditorApi) => {
void (async () => {
const version = await api.version();
const result = await api.applyEdits({
expectVersion: version,
steps: [
{
op: "setCellInputs",
target: { sheetId: "sheet:0", range: { kind: "a1", a1: "B3" } },
inputs: [["120"]],
},
],
});
if (!result.ok) console.warn(result.failure.code);
})().catch((error: unknown) => {
if (error instanceof XlsxCommandAdmissionError) console.warn(error.code);
else throw error;
});
}}
/>;See @betteroffice/xlsx for
the step vocabulary and its limits.
Host plugins
Host-owned tools (review aids, checks, templates) install through the plugins
prop. A plugin contributes a docked panel, an overlay on the grid and commands,
and works through restricted clients rather than the editor API or the workbook
handle:
import { XlsxEditor, defineXlsxPlugin } from "@betteroffice/xlsx-react";
type State = { version: string | null; sheets: number };
const review = defineXlsxPlugin<State>({
id: "acme.review",
createState: () => ({ version: null, sheets: 0 }),
async onEvent(context, event) {
if (event.type !== "load" && event.type !== "document-change") return;
const read = await context.read.readCells({ ranges: [] });
if (read.ok) context.setState({ version: read.version, sheets: read.sheets.length }, read.version);
},
panel: {
title: "Review",
placement: "right",
render: ({ context }) => <p>{context.state.sheets} sheets</p>,
},
overlay: ({ context, geometry }) => {
const selection = context.snapshot.selection;
const focus = selection?.cells?.focus;
const box = selection && focus ? geometry.getCellRect({ sheetId: selection.sheetId, ...focus }) : null;
return box ? (
<div style={{ position: "absolute", left: box.x, top: box.y, width: box.width, height: box.height, outline: "2px dashed #2563eb" }} />
) : null;
},
});
<XlsxEditor
file={bytes}
plugins={[review]}
pluginGrants={{ "acme.review": { document: "write", editBatches: true } }}
onPluginError={(error) => report(error)}
/>;- Lifecycle. Once a workbook is open, each plugin gets fresh state,
initialize, then oneloadevent (loaded,replaced, orattachedfor a plugin added to an open workbook), and its contributions appear. A workbook change the hook did not make itself aborts it and deliversloadagain; after ten such runs the plugin is stopped and reported. It then receivesdocument-change(the committed version after recalculation, for typing, pastes, commands, batches, its own included, undo, redo and remote updates, never for refusals or no-ops),selection-change,mode-change(withreadOnly),layout-changeandgrants-change. Events describe current state: several changes may arrive as one, and a newer one aborts the hook still handling the previous (context.signal), except a change that hook's own edit batch made. Replacing the workbook, removing the plugin, changing itsrevision, unmounting, or a failure ends the activation: its signals abort, its clients refuse, and everyonCleanupdisposer runs once with the reason. Plugins are matched byidandrevision, so new array or callback identities and reordering keep their state.defineXlsxPlugincopies the panel, commands and toolbar, so changing them takes a new definition.context.run(action)gives event handlers a fresh context and isolates their failures.onReadyis unaffected by plugins. - Stale results.
setStatereturns false once the context is superseded or ended, or when the workbook is no longer atatVersion(by default the version the context was created at). - Reads and edits.
context.readoffersversion,readCells,findTextandvalidateEdits, andcontext.edits.applyEditsthe editor's version-checked batches. Each call waits for pending input in order with it, but plugin handlers run outside that queue, so a command can await its own batch. - Grants. Without a grant a plugin reads, validates and navigates only, and
context.editsis null. Built-in commands need their id incommands, and mutating ones alsodocument: "write"; edit batches needdocument: "write"andeditBatches, andhistory: "none"alsountrackedHistory. The grant andreadOnlyare checked again right before each change, so a revoked grant,readOnlyor a replaced workbook refuses even through a client obtained earlier. Mutating built-in commands refuse plugins withunsupported-policy; plugins change workbooks through edit batches. Grants govern the plugin API, separately from sheet and workbook protection. - Contributed commands register as
plugin:<pluginId>/<id>onapi.commands. They always run with their own plugin's clients, even when the toolbar, a shortcut or the host invokes them.executereturns{ ok: true, status }or a failure with the plugin's own code, or a refused edit batch as-is; callers receive it unchanged.mutatesDocumentdisables a command while read-only but grants nothing. Plugin shortcuts must use Mod or Alt, or a function key; built-in shortcuts win and a clashing plugin shortcut is reported.toolbarlists local ids in order: the default toolbar shows them, and replacement chrome places<XlsxPluginToolbar />where it wants them.ToolbarCommandButton,ToolbarCommand,useXlsxCommandanduseXlsxCommandStatealso take a contributed id. - Selection and navigation.
snapshot.selectionnames the active sheet (sheetIdand the zero-basedsheetIndex), the selectedcellswith theiranchorandfocusdirection, and thechartIdof a selected chart.navigation.selectCells({ sheetId, selection }, { expectVersion, focus })activates the sheet, selects the cells and reveals the focus cell.navigation.scrollToCell({ sheetId, row, col }, { expectVersion, align })reveals a cell (nearestby default,startorcenter) and keeps the selection; a cell on another sheet activates it with nothing selected. Both resolve sheet ids after pending input againstexpectVersion, keep keyboard focus unlessselectCellsgetsfocus: true, and refuse withstale-versionormissing-targetrather than retarget. Sheet ids and versions are session-scoped. - Geometry.
context.geometryexists only while the canvas shows a painted frame of the current version.layoutcarries the frame'ssheetId,zoomand paintedviewport(unzoomed sheet pixels), and gets a newidwith every scroll, zoom, resize and sheet switch.getCellRect({ sheetId, row, col })andgetRangeRect({ sheetId, range })return pixels of the overlay layer, zoomed and clipped to the visible grid with frozen panes placed as painted; cells scrolled out of view or on another sheet return null.getCellRectis one grid cell; address a merged area as a range.getPositionAtPointis null; check it before calling. Every method returns null once its layout is gone. The overlay layer sits on the grid below the editor's selection and cell editor, and ignores the pointer unless an element setspointer-events: auto. - Panels dock left, right or bottom of the grid, below the toolbar and
above the sheet tabs, with tabs when several share a side.
preferredSizeis clamped to 40% of the workspace. In a narrow editor side docks show their tabs, and a tab opens its panel as a drawer that Escape closes. Collapsing a panel keeps the plugin running. The proposals panel opens inside the grid area. - Failures. Every contribution renders behind its own error boundary with a
command context restricted to its plugin. Keys pressed in plugin chrome are
the user's: built-in shortcuts act on the editor there, a plugin's text fields
keep their own editing keys, and no key or click in a contribution reaches the
grid. A throwing hook, renderer, command-state function, command or
context.runaction stops only that plugin, runs its cleanups and reports{ pluginId, generation, phase, error }toonPluginError. Plugins run in the page's realm: grants govern the supported API, not a sandbox.
Collaboration
Pass collaboration to open a network-ready replica, then attach a transport
provider from onReady:
import { CollaborationProvider } from "@betteroffice/xlsx/collaboration";
<XlsxEditor
file={file}
collaboration={{ clientId }}
onReady={({ handle }) => {
const provider = new CollaborationProvider(handle, transport, {
user: { name: "Ada" }, // shown on this peer's selection flag
});
provider.connect();
return () => provider.destroy();
}}
/>;JavaScript guide · Changelog · Apache-2.0.
