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