@eristack/spreadsheet-operator
Version:
Headless spreadsheet keyboard operator — active-grid scope, cell navigation, Excel-like Enter/Tab editing
701 lines (692 loc) • 23.7 kB
JavaScript
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/react/index.ts
var react_exports = {};
__export(react_exports, {
SpreadsheetGridIdProvider: () => SpreadsheetGridIdProvider,
SpreadsheetNavCell: () => SpreadsheetNavCell,
SpreadsheetScopeProvider: () => SpreadsheetScopeProvider,
SpreadsheetTable: () => SpreadsheetTable,
SpreadsheetTextCell: () => SpreadsheetTextCell,
isKeyTargetOutsideActiveGrid: () => isKeyTargetOutsideActiveGrid,
isPointerTargetOutsideGrids: () => isPointerTargetOutsideGrids,
useSpreadsheetCellEditor: () => useSpreadsheetCellEditor,
useSpreadsheetGrid: () => useSpreadsheetGrid,
useSpreadsheetGridId: () => useSpreadsheetGridId,
useSpreadsheetScope: () => useSpreadsheetScope
});
module.exports = __toCommonJS(react_exports);
// src/react/scope.tsx
var import_react = require("react");
// src/core/types.ts
var DEFAULT_NAVIGABLE_KINDS = ["editable", "select"];
var DEFAULT_EDITABLE_KINDS = ["editable", "select"];
// src/core/address.ts
function sameAddress(a, b) {
return a.row === b.row && a.col === b.col;
}
function cellKey(gridId, address) {
return `${gridId}:${address.row}:${address.col}`;
}
function inBounds(address, grid) {
return address.row >= 0 && address.col >= 0 && address.row < grid.rowCount && address.col < grid.colCount;
}
function clampAddress(address, grid) {
if (grid.rowCount < 1 || grid.colCount < 1) {
return { row: 0, col: 0 };
}
return {
row: Math.min(Math.max(0, address.row), grid.rowCount - 1),
col: Math.min(Math.max(0, address.col), grid.colCount - 1)
};
}
function compareRaster(a, b) {
if (a.row !== b.row) return a.row - b.row;
return a.col - b.col;
}
// src/core/navigation.ts
function resolveConfig(config = {}) {
return {
wrap: config.wrap ?? "wrap",
arrowWrap: config.arrowWrap ?? "stop",
enterMove: config.enterMove ?? "down",
typeToEdit: config.typeToEdit ?? true,
navigableKinds: config.navigableKinds ?? DEFAULT_NAVIGABLE_KINDS,
editableKinds: config.editableKinds ?? DEFAULT_EDITABLE_KINDS,
arrowInEdit: config.arrowInEdit ?? "caret"
};
}
function kindAt(grid, address) {
if (!inBounds(address, grid)) return void 0;
return grid.cellAt(address).kind;
}
function isKind(kind, allowed) {
return kind !== void 0 && allowed.includes(kind);
}
function isNavigable(grid, address, config) {
return isKind(kindAt(grid, address), config.navigableKinds);
}
function isEditable(grid, address, config) {
return isKind(kindAt(grid, address), config.editableKinds);
}
function listNavigableAddresses(grid, config) {
const out = [];
for (let row = 0; row < grid.rowCount; row += 1) {
for (let col = 0; col < grid.colCount; col += 1) {
const address = { row, col };
if (isNavigable(grid, address, config)) out.push(address);
}
}
return out;
}
function step(address, direction) {
switch (direction) {
case "up":
return { row: address.row - 1, col: address.col };
case "down":
return { row: address.row + 1, col: address.col };
case "left":
return { row: address.row, col: address.col - 1 };
case "right":
return { row: address.row, col: address.col + 1 };
}
}
function wrapArrowStart(address, direction, grid) {
switch (direction) {
case "up":
return { row: grid.rowCount - 1, col: address.col };
case "down":
return { row: 0, col: address.col };
case "left":
return { row: address.row, col: grid.colCount - 1 };
case "right":
return { row: address.row, col: 0 };
}
}
function moveActiveAddress(grid, from, direction, config) {
if (grid.rowCount < 1 || grid.colCount < 1) return from;
const origin = clampAddress(from, grid);
let cursor = origin;
const limit = Math.max(grid.rowCount, grid.colCount) + 1;
for (let i = 0; i < limit; i += 1) {
const next = step(cursor, direction);
if (!inBounds(next, grid)) {
if (config.arrowWrap === "stop") return origin;
cursor = wrapArrowStart(origin, direction, grid);
} else {
cursor = next;
}
if (sameAddress(cursor, origin)) return origin;
if (isNavigable(grid, cursor, config)) return cursor;
}
return origin;
}
function nextAfterRaster(list, from, direction) {
if (direction === "next") {
return list.find((cell) => compareRaster(cell, from) > 0);
}
for (let i = list.length - 1; i >= 0; i -= 1) {
const cell = list[i];
if (cell && compareRaster(cell, from) < 0) return cell;
}
return void 0;
}
function tabActiveAddress(grid, from, direction, config) {
const list = listNavigableAddresses(grid, config);
if (list.length === 0) return from;
const idx = list.findIndex((cell) => sameAddress(cell, from));
if (idx === -1) {
const found = nextAfterRaster(list, from, direction);
if (found) return found;
if (config.wrap === "wrap") {
return direction === "next" ? list[0] ?? from : list[list.length - 1] ?? from;
}
return from;
}
const delta = direction === "next" ? 1 : -1;
const nextIdx = idx + delta;
if (nextIdx >= 0 && nextIdx < list.length) {
return list[nextIdx] ?? from;
}
if (config.wrap === "wrap") {
return direction === "next" ? list[0] ?? from : list[list.length - 1] ?? from;
}
return from;
}
// src/core/keymap.ts
var ARROWS = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right"
};
function isPrintable(key) {
return key.length === 1;
}
function keyEventToAction(state, event, config) {
if (event.isComposing) return void 0;
if (event.ctrlKey || event.metaKey || event.altKey) return void 0;
const arrow = ARROWS[event.key];
if (arrow) {
if (state.mode === "inactive") return void 0;
if (state.mode === "editing" && config.arrowInEdit === "caret") return void 0;
return { type: "move", direction: arrow };
}
if (event.key === "Tab") {
if (state.mode === "inactive") return void 0;
return { type: "tab", shift: event.shiftKey === true };
}
if (event.key === "Enter") {
if (state.mode === "inactive") return void 0;
return { type: "enter" };
}
if (event.key === "Escape") {
if (state.mode === "inactive") return void 0;
return { type: "escape" };
}
if (event.key === "F2") {
if (state.mode !== "active") return void 0;
return { type: "f2" };
}
if (state.mode === "active" && config.typeToEdit && isPrintable(event.key)) {
return { type: "type", char: event.key };
}
return void 0;
}
// src/core/reduce.ts
function fieldKeyAt(grid, address) {
const cell = grid.cellAt(address);
return cell.fieldKey ?? cell.id;
}
function commitEffect(gridId, address, ctx) {
const grid = ctx.grids.get(gridId);
return {
type: "commit",
gridId,
address,
fieldKey: grid ? fieldKeyAt(grid, address) : void 0,
phase: "commit"
};
}
function cancelEffect(gridId, address) {
return { type: "cancel", gridId, address, phase: "cancel" };
}
function startEditEffect(gridId, address, seed) {
return { type: "startEdit", gridId, address, seed };
}
function gridOf(ctx, gridId) {
return ctx.grids.get(gridId);
}
function editingOf(state) {
return state.mode === "editing" ? state : void 0;
}
function positionedOf(state) {
return state.mode === "inactive" ? void 0 : state;
}
function commitIfEditing(state, ctx) {
const editing = editingOf(state);
if (!editing) return [];
return [commitEffect(editing.gridId, editing.address, ctx)];
}
function enterMoveAddress(grid, from, ctx) {
if (ctx.config.enterMove === "none") return from;
const direction = ctx.config.enterMove === "right" ? "right" : "down";
return moveActiveAddress(grid, from, direction, ctx.config);
}
function reduceOperator(state, action, ctx) {
switch (action.type) {
case "activate": {
const grid = gridOf(ctx, action.gridId);
if (!grid || grid.rowCount < 1 || grid.colCount < 1) {
return { state, effects: [] };
}
const address = clampAddress(action.address, grid);
const effects = commitIfEditing(state, ctx);
const next = {
mode: "active",
gridId: action.gridId,
address
};
if (state.mode === "active" && state.gridId === next.gridId && sameAddress(state.address, next.address) && effects.length === 0) {
return { state, effects: [] };
}
return { state: next, effects };
}
case "deactivate": {
if (state.mode === "inactive") return { state, effects: [] };
return { state: { mode: "inactive" }, effects: commitIfEditing(state, ctx) };
}
case "move": {
const pos = positionedOf(state);
if (!pos) return { state, effects: [] };
if (state.mode === "editing" && ctx.config.arrowInEdit === "caret") {
return { state, effects: [] };
}
const grid = gridOf(ctx, pos.gridId);
if (!grid) return { state, effects: [] };
const effects = commitIfEditing(state, ctx);
const address = moveActiveAddress(grid, pos.address, action.direction, ctx.config);
return {
state: { mode: "active", gridId: pos.gridId, address },
effects
};
}
case "tab": {
const pos = positionedOf(state);
if (!pos) return { state, effects: [] };
const grid = gridOf(ctx, pos.gridId);
if (!grid) return { state, effects: [] };
const effects = commitIfEditing(state, ctx);
const address = tabActiveAddress(
grid,
pos.address,
action.shift ? "prev" : "next",
ctx.config
);
return {
state: { mode: "active", gridId: pos.gridId, address },
effects
};
}
case "enter": {
if (state.mode === "inactive") return { state, effects: [] };
const grid = gridOf(ctx, state.gridId);
if (!grid) return { state, effects: [] };
if (state.mode === "active") {
if (!isEditable(grid, state.address, ctx.config)) {
return { state, effects: [] };
}
return {
state: { mode: "editing", gridId: state.gridId, address: state.address },
effects: [startEditEffect(state.gridId, state.address)]
};
}
const address = enterMoveAddress(grid, state.address, ctx);
return {
state: { mode: "active", gridId: state.gridId, address },
effects: [commitEffect(state.gridId, state.address, ctx)]
};
}
case "escape": {
if (state.mode === "inactive") return { state, effects: [] };
if (state.mode === "editing") {
return {
state: { mode: "active", gridId: state.gridId, address: state.address },
effects: [cancelEffect(state.gridId, state.address)]
};
}
return { state: { mode: "inactive" }, effects: [] };
}
case "f2": {
if (state.mode !== "active") return { state, effects: [] };
const grid = gridOf(ctx, state.gridId);
if (!grid || !isEditable(grid, state.address, ctx.config)) {
return { state, effects: [] };
}
return {
state: { mode: "editing", gridId: state.gridId, address: state.address },
effects: [startEditEffect(state.gridId, state.address)]
};
}
case "type": {
if (!ctx.config.typeToEdit) return { state, effects: [] };
if (state.mode !== "active") return { state, effects: [] };
const grid = gridOf(ctx, state.gridId);
if (!grid || !isEditable(grid, state.address, ctx.config)) {
return { state, effects: [] };
}
return {
state: {
mode: "editing",
gridId: state.gridId,
address: state.address,
seed: action.char
},
effects: [startEditEffect(state.gridId, state.address, action.char)]
};
}
case "commit": {
if (state.mode !== "editing") return { state, effects: [] };
return {
state: { mode: "active", gridId: state.gridId, address: state.address },
effects: [commitEffect(state.gridId, state.address, ctx)]
};
}
case "cancel": {
if (state.mode !== "editing") return { state, effects: [] };
return {
state: { mode: "active", gridId: state.gridId, address: state.address },
effects: [cancelEffect(state.gridId, state.address)]
};
}
}
}
// src/core/create-operator.ts
function createSpreadsheetOperator(config = {}) {
const resolved = resolveConfig(config);
let state = { mode: "inactive" };
const grids = /* @__PURE__ */ new Map();
const listeners = /* @__PURE__ */ new Set();
function notify(effects) {
for (const listener of listeners) listener(state, effects);
}
function dispatch(action) {
const result = reduceOperator(state, action, { grids, config: resolved });
if (result.state === state && result.effects.length === 0) {
return result;
}
state = result.state;
notify(result.effects);
return result;
}
return {
getState() {
return state;
},
dispatch,
handleKeyDown(event) {
const action = keyEventToAction(state, event, resolved);
if (!action) return false;
const before = state;
const result = dispatch(action);
return result.state !== before || result.effects.length > 0;
},
registerGrid(descriptor) {
grids.set(descriptor.id, descriptor);
},
unregisterGrid(gridId) {
const active = state.mode !== "inactive" && state.gridId === gridId;
if (active) dispatch({ type: "deactivate" });
grids.delete(gridId);
},
getGrid(gridId) {
return grids.get(gridId);
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
};
}
// src/react/scope.tsx
var ScopeContext = (0, import_react.createContext)(null);
var GridIdContext = (0, import_react.createContext)(null);
function useSpreadsheetScope() {
const ctx = (0, import_react.useContext)(ScopeContext);
if (!ctx) {
throw new Error("useSpreadsheetScope must be used within SpreadsheetScopeProvider");
}
return ctx;
}
function useSpreadsheetGridId() {
const id = (0, import_react.useContext)(GridIdContext);
if (!id) {
throw new Error(
"useSpreadsheetGridId must be used within SpreadsheetTable or SpreadsheetGridIdProvider"
);
}
return id;
}
function SpreadsheetGridIdProvider({ gridId, children }) {
return (0, import_react.createElement)(GridIdContext.Provider, { value: gridId }, children);
}
function applyEditorEffects(editors, effects, onCommit) {
for (const effect of effects) {
const editor = editors.get(cellKey(effect.gridId, effect.address));
if (effect.type === "commit") {
const value = editor?.readValue() ?? "";
editor?.onCommit?.(value);
onCommit?.({
gridId: effect.gridId,
address: effect.address,
fieldKey: effect.fieldKey,
value,
phase: "commit"
});
}
if (effect.type === "cancel") {
editor?.onCancel?.();
}
if (effect.type === "startEdit" && effect.seed !== void 0) {
editor?.writeValue?.(effect.seed);
}
}
}
var EDITABLE_TARGET_SELECTOR = "input, textarea, select, [contenteditable=''], [contenteditable='true']";
function isKeyTargetOutsideActiveGrid(target, activeGridId) {
if (!target) return false;
const editable = target.isContentEditable === true || typeof target.matches === "function" && target.matches(EDITABLE_TARGET_SELECTOR);
if (!editable) return false;
const grid = target.closest("[data-spreadsheet-grid]");
return grid?.getAttribute("data-spreadsheet-grid") !== activeGridId;
}
function isPointerTargetOutsideGrids(target) {
if (!target) return false;
return target.closest("[data-spreadsheet-grid]") === null;
}
function SpreadsheetScopeProvider({
children,
config,
onCommit,
deactivateOnOutsidePointerDown = true
}) {
const editorsRef = (0, import_react.useRef)(/* @__PURE__ */ new Map());
const onCommitRef = (0, import_react.useRef)(onCommit);
onCommitRef.current = onCommit;
const operatorRef = (0, import_react.useRef)(null);
if (operatorRef.current === null) {
const created = createSpreadsheetOperator(config);
created.subscribe((_state, effects) => {
applyEditorEffects(editorsRef.current, effects, onCommitRef.current);
});
operatorRef.current = created;
}
const operator = operatorRef.current;
const state = (0, import_react.useSyncExternalStore)(
(onStoreChange) => operator.subscribe(() => onStoreChange()),
operator.getState,
operator.getState
);
(0, import_react.useEffect)(() => {
function onKeyDown(event) {
const current = operator.getState();
if (current.mode === "inactive") return;
if (isKeyTargetOutsideActiveGrid(
event.target instanceof Element ? event.target : null,
current.gridId
)) {
return;
}
if (operator.handleKeyDown(event)) {
event.preventDefault();
event.stopPropagation();
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [operator]);
(0, import_react.useEffect)(() => {
if (!deactivateOnOutsidePointerDown) return;
function onPointerDown(event) {
if (operator.getState().mode === "inactive") return;
if (isPointerTargetOutsideGrids(event.target instanceof Element ? event.target : null)) {
operator.dispatch({ type: "deactivate" });
}
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [operator, deactivateOnOutsidePointerDown]);
const registerEditor = (0, import_react.useCallback)(
(gridId, address, editor) => {
const key = cellKey(gridId, address);
editorsRef.current.set(key, editor);
return () => {
editorsRef.current.delete(key);
};
},
[]
);
const api = (0, import_react.useMemo)(
() => ({ operator, state, registerEditor }),
[operator, state, registerEditor]
);
return (0, import_react.createElement)(ScopeContext.Provider, { value: api }, children);
}
function useSpreadsheetGrid(descriptor) {
const { operator, state } = useSpreadsheetScope();
operator.registerGrid(descriptor);
(0, import_react.useEffect)(() => {
const id = descriptor.id;
return () => operator.unregisterGrid(id);
}, [operator, descriptor.id]);
const isGridActive = state.mode !== "inactive" && state.gridId === descriptor.id;
const activeAddress = isGridActive ? state.address : null;
const editing = state.mode === "editing" && state.gridId === descriptor.id;
const onMouseDown = (0, import_react.useCallback)(
(event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const cell = target.closest("[data-spreadsheet-cell]");
if (!cell) return;
const row = Number(cell.getAttribute("data-row"));
const col = Number(cell.getAttribute("data-col"));
if (!Number.isFinite(row) || !Number.isFinite(col)) return;
operator.dispatch({
type: "activate",
gridId: descriptor.id,
address: { row, col }
});
},
[operator, descriptor.id]
);
return {
gridProps: {
role: "grid",
"data-spreadsheet-grid": descriptor.id,
"data-spreadsheet-active": isGridActive ? "" : void 0,
tabIndex: -1,
onMouseDown
},
activeAddress,
editing,
isGridActive
};
}
function SpreadsheetTable({ descriptor, children, className }) {
const { gridProps } = useSpreadsheetGrid(descriptor);
return (0, import_react.createElement)(
GridIdContext.Provider,
{ value: descriptor.id },
(0, import_react.createElement)("table", { ...gridProps, className }, children)
);
}
function SpreadsheetNavCell({ address, children, className }) {
const gridId = useSpreadsheetGridId();
const { state } = useSpreadsheetScope();
const isActive = state.mode !== "inactive" && state.gridId === gridId && sameAddress(state.address, address);
const isEditing = state.mode === "editing" && isActive;
return (0, import_react.createElement)(
"td",
{
role: "gridcell",
className,
"data-spreadsheet-cell": "",
"data-row": address.row,
"data-col": address.col,
"data-active": isActive ? "" : void 0,
"data-editing": isEditing ? "" : void 0,
"aria-selected": isActive,
tabIndex: isActive ? 0 : -1
},
children
);
}
function useSpreadsheetCellEditor(options) {
const gridId = useSpreadsheetGridId();
const { state, registerEditor } = useSpreadsheetScope();
const editing = state.mode === "editing" && state.gridId === gridId && sameAddress(state.address, options.address);
const seed = editing && state.mode === "editing" ? state.seed : void 0;
const optionsRef = (0, import_react.useRef)(options);
optionsRef.current = options;
const row = options.address.row;
const col = options.address.col;
(0, import_react.useLayoutEffect)(() => {
return registerEditor(gridId, { row, col }, {
focus: () => optionsRef.current.focus?.(),
selectAll: () => optionsRef.current.selectAll?.(),
readValue: () => optionsRef.current.readValue(),
writeValue: (value) => optionsRef.current.writeValue?.(value),
onCommit: (value) => optionsRef.current.onCommit?.(value),
onCancel: () => optionsRef.current.onCancel?.()
});
}, [gridId, row, col, registerEditor]);
return { editing, seed };
}
function SpreadsheetTextCell({ address, value, onCommit }) {
const inputRef = (0, import_react.useRef)(null);
const { operator } = useSpreadsheetScope();
const { editing, seed } = useSpreadsheetCellEditor({
address,
readValue: () => inputRef.current?.value ?? value,
onCommit,
focus: () => inputRef.current?.focus(),
selectAll: () => inputRef.current?.select()
});
(0, import_react.useLayoutEffect)(() => {
if (!editing) return;
const el = inputRef.current;
if (!el) return;
el.focus();
if (seed === void 0) el.select();
}, [editing, seed]);
if (!editing) {
return (0, import_react.createElement)("span", { "data-spreadsheet-display": "" }, value);
}
return (0, import_react.createElement)("input", {
ref: inputRef,
defaultValue: seed ?? value,
"data-spreadsheet-editor": "",
onKeyDown: (event) => {
if (event.key === "Enter" || event.key === "Tab" || event.key === "Escape") {
return;
}
event.stopPropagation();
},
onBlur: () => {
operator.dispatch({ type: "commit" });
}
});
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
SpreadsheetGridIdProvider,
SpreadsheetNavCell,
SpreadsheetScopeProvider,
SpreadsheetTable,
SpreadsheetTextCell,
isKeyTargetOutsideActiveGrid,
isPointerTargetOutsideGrids,
useSpreadsheetCellEditor,
useSpreadsheetGrid,
useSpreadsheetGridId,
useSpreadsheetScope
});
//# sourceMappingURL=index.cjs.map