UNPKG

@eristack/spreadsheet-operator

Version:

Headless spreadsheet keyboard operator — active-grid scope, cell navigation, Excel-like Enter/Tab editing

280 lines (279 loc) • 9.06 kB
import { cellKey, createSpreadsheetOperator, sameAddress } from "../chunk-CC76QBJX.js"; // src/react/scope.tsx import { createContext, createElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from "react"; var ScopeContext = createContext(null); var GridIdContext = createContext(null); function useSpreadsheetScope() { const ctx = useContext(ScopeContext); if (!ctx) { throw new Error("useSpreadsheetScope must be used within SpreadsheetScopeProvider"); } return ctx; } function useSpreadsheetGridId() { const id = useContext(GridIdContext); if (!id) { throw new Error( "useSpreadsheetGridId must be used within SpreadsheetTable or SpreadsheetGridIdProvider" ); } return id; } function SpreadsheetGridIdProvider({ gridId, children }) { return 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 = useRef(/* @__PURE__ */ new Map()); const onCommitRef = useRef(onCommit); onCommitRef.current = onCommit; const operatorRef = 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 = useSyncExternalStore( (onStoreChange) => operator.subscribe(() => onStoreChange()), operator.getState, operator.getState ); 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]); 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 = useCallback( (gridId, address, editor) => { const key = cellKey(gridId, address); editorsRef.current.set(key, editor); return () => { editorsRef.current.delete(key); }; }, [] ); const api = useMemo( () => ({ operator, state, registerEditor }), [operator, state, registerEditor] ); return createElement(ScopeContext.Provider, { value: api }, children); } function useSpreadsheetGrid(descriptor) { const { operator, state } = useSpreadsheetScope(); operator.registerGrid(descriptor); 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 = 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 createElement( GridIdContext.Provider, { value: descriptor.id }, 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 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 = useRef(options); optionsRef.current = options; const row = options.address.row; const col = options.address.col; 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 = useRef(null); const { operator } = useSpreadsheetScope(); const { editing, seed } = useSpreadsheetCellEditor({ address, readValue: () => inputRef.current?.value ?? value, onCommit, focus: () => inputRef.current?.focus(), selectAll: () => inputRef.current?.select() }); useLayoutEffect(() => { if (!editing) return; const el = inputRef.current; if (!el) return; el.focus(); if (seed === void 0) el.select(); }, [editing, seed]); if (!editing) { return createElement("span", { "data-spreadsheet-display": "" }, value); } return 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" }); } }); } export { SpreadsheetGridIdProvider, SpreadsheetNavCell, SpreadsheetScopeProvider, SpreadsheetTable, SpreadsheetTextCell, isKeyTargetOutsideActiveGrid, isPointerTargetOutsideGrids, useSpreadsheetCellEditor, useSpreadsheetGrid, useSpreadsheetGridId, useSpreadsheetScope }; //# sourceMappingURL=index.js.map