UNPKG

@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
"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