Skip to content

Commit 221798b

Browse files
more expressions support - Sum, AVG etc (#58)
* more expressions support - Sum, AVG etc * lint * more expressions support - Sum, AVG etc * more expressions support - Sum, AVG etc * more expressions support - Sum, AVG etc * version * version * more expressions support - Sum, AVG etc
1 parent fb238f1 commit 221798b

11 files changed

Lines changed: 1025 additions & 78 deletions

File tree

‎README.md‎

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515

1616
- ⚡ **Blazing Fast** - Optimized virtual scrolling renders 100,000+ cells smoothly
1717
- 🪶 **Ultra Lightweight** - Only ~300KB, no heavy dependencies
18-
- 🧮 **Excel-like Formulas** - Full calculation engine with cell references (=A1*B2+C3)
18+
- 🧮 **Excel-like Formulas** - Full calculation engine: SUM, AVERAGE, COUNT, MIN, MAX, IF, CONCAT, ROUND, ABS, SQRT, POWER, expressions like - (=A1*B2+C3) and more
19+
- 🔣 **Σ Formula Toolbar** - Insert range and single-cell formulas instantly with the Σ button
20+
- 💡 **fx Bar with Autocomplete** - Live formula bar with function suggestions as you type
1921
- 🎨 **Rich Formatting** - Bold, italic, underline, colors, alignment, and more
2022
- 📊 **Merge Cells** - Combine cells like in Excel
2123
- ↩️ **Undo/Redo** - Full history management
@@ -106,19 +108,46 @@ Our spreadsheet uses advanced virtual scrolling and memoization techniques to ha
106108

107109
### 🧮 Excel-like Formulas
108110

109-
Full calculation engine supporting:
111+
Full calculation engine supporting cell references, ranges, and built-in functions:
110112

111113
```javascript
112-
// Cell references
114+
// Basic arithmetic with cell references
113115
=A1 + B2
114-
115-
// Complex formulas
116116
=A1 * B2 + (C3 - D4) / E5
117-
118-
// Multiple operations
119117
=(A1 + A2 + A3) * 0.1
118+
119+
// Range formulas (Σ toolbar or type directly)
120+
=SUM(A1:A10) // Sum a range
121+
=AVERAGE(B1:B5) // Mean of a range
122+
=COUNT(C1:C10) // Count numeric cells
123+
=COUNTA(D1:D10) // Count all non-empty cells
124+
=MIN(A1:A10) // Smallest value
125+
=MAX(A1:A10) // Largest value
126+
=CONCAT(A1:A3) // Join text in a range
127+
128+
// Single-cell functions
129+
=ABS(A1) // Absolute value
130+
=SQRT(A1) // Square root
131+
=ROUND(A1, 2) // Round to N decimal places
132+
=POWER(A1, 3) // Raise to a power
133+
=IF(A1>0, A1, 0) // Conditional expression
120134
```
121135

136+
#### Σ Formula Toolbar
137+
138+
Click the **Σ** button in the toolbar to insert a formula into the selected cell:
139+
140+
- **Range formulas** (SUM, AVERAGE, COUNT, COUNTA, MIN, MAX, CONCAT) — built from the current selection, result placed in the cell below
141+
- **Single-cell formulas** (ABS, SQRT, ROUND, POWER, IF) — applied to the selected cell's address
142+
143+
#### fx Bar Autocomplete
144+
145+
Type `=` followed by letters in the fx bar (e.g. `=SU`) to get a dropdown of matching functions with descriptions. Use **↑ ↓** to navigate, **Enter** or **Tab** to apply.
146+
147+
#### Live Reference Highlights
148+
149+
While typing a formula, all referenced cells glow with a **blue dashed border** and the relevant column/row headers highlight in blue — so you always know what's in scope.
150+
122151
### 🎨 Rich Text Formatting
123152

124153
```tsx
@@ -331,9 +360,11 @@ const mergedData = [
331360
| `Ctrl/Cmd + I` | Italic |
332361
| `Ctrl/Cmd + U` | Underline |
333362
| `Ctrl/Cmd + A` | Select all |
334-
| `Delete/Backspace` | Clear selected cells |
335-
| `Enter` | Edit cell |
363+
| `Delete/Backspace` | Clear selected cells (only when no input field is focused) |
364+
| `Double-click` / `Enter` | Edit cell |
336365
| `Esc` | Cancel edit |
366+
| `↑ ↓` in fx bar | Navigate formula autocomplete suggestions |
367+
| `Enter` / `Tab` in fx bar | Apply selected formula suggestion |
337368

338369
## 🧪 Testing
339370

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-spread-sheet-excel",
3-
"version": "3.4.0",
3+
"version": "3.4.1",
44
"description": "Lightning-fast React spreadsheet component with Excel-like features. Render 100,000+ cells with formulas, formatting, copy/paste, undo/redo. Perfect for data grids and Excel-like apps.",
55
"keywords": [
66
"react-spread-sheet-excel",

‎rollup.config.js‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export default {
3535
],
3636
plugins: [
3737
peerDepsExternal(),
38+
resolve({
39+
extensions: ['.ts', '.tsx', '.js', '.jsx']
40+
}),
41+
commonjs(),
3842
typescript({
3943
tsconfig: './tsconfig.json',
4044
compilerOptions: {
@@ -44,10 +48,6 @@ export default {
4448
rootDir: './src/lib'
4549
}
4650
}),
47-
resolve({
48-
extensions: ['.ts', '.tsx', '.js', '.jsx']
49-
}),
50-
commonjs(),
5151
postcss(),
5252
copyCssPlugin()
5353
],

‎src/lib/list/formula-edit-state.ts‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Formula reference highlights.
3+
*
4+
* Manages DOM-level highlights for cells/ranges referenced in the formula
5+
* being edited. Applied directly via setAttribute to avoid React re-renders.
6+
*/
7+
8+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
9+
10+
const colLabelToIndex = (label: string, headerValues?: string[]): number => {
11+
const values = headerValues && headerValues.length ? headerValues : alphabet;
12+
if (typeof values === "string") {
13+
let n = 0;
14+
for (const ch of label.toUpperCase()) {
15+
n = n * 26 + (alphabet.indexOf(ch) + 1);
16+
}
17+
return n - 1;
18+
}
19+
return (values as string[]).indexOf(label);
20+
};
21+
22+
const extractRefs = (
23+
formula: string,
24+
headerValues?: string[],
25+
gridRows = 0,
26+
gridCols = 0,
27+
): Array<[number, number]> => {
28+
const cells: Array<[number, number]> = [];
29+
const rangeRe = /([A-Z]+)(\d+):([A-Z]+)(\d+)/gi;
30+
const cellRe = /\b([A-Z]+)(\d+)\b/gi;
31+
const rangeMatches: Array<{ start: number; end: number }> = [];
32+
let m: RegExpExecArray | null;
33+
34+
rangeRe.lastIndex = 0;
35+
while ((m = rangeRe.exec(formula)) !== null) {
36+
rangeMatches.push({ start: m.index, end: m.index + m[0].length });
37+
const c1 = colLabelToIndex(m[1], headerValues);
38+
const r1 = parseInt(m[2], 10) - 1;
39+
const c2 = colLabelToIndex(m[3], headerValues);
40+
const r2 = parseInt(m[4], 10) - 1;
41+
const rMin = Math.min(r1, r2);
42+
const rMax = Math.max(r1, r2);
43+
const cMin = Math.min(c1, c2);
44+
const cMax = Math.max(c1, c2);
45+
for (let r = rMin; r <= rMax; r++)
46+
for (let c = cMin; c <= cMax; c++)
47+
if (gridRows === 0 || (r < gridRows && c < gridCols)) cells.push([r, c]);
48+
}
49+
50+
cellRe.lastIndex = 0;
51+
while ((m = cellRe.exec(formula)) !== null) {
52+
if (!rangeMatches.some((rng) => m!.index >= rng.start && m!.index < rng.end)) {
53+
const c = colLabelToIndex(m[1], headerValues);
54+
const r = parseInt(m[2], 10) - 1;
55+
if (gridRows === 0 || (r < gridRows && c < gridCols)) cells.push([r, c]);
56+
}
57+
}
58+
return cells;
59+
};
60+
61+
let _highlightedIds: string[] = [];
62+
let _highlightedAxes: { el: Element; attr: string }[] = [];
63+
64+
/**
65+
* Parse the formula and set `data-formula-ref` on referenced cell DOM elements.
66+
* Also highlights column <th> and row axis <td> headers.
67+
* Pure DOM — zero React re-renders.
68+
*/
69+
export const updateFormulaHighlights = (
70+
formulaValue: string,
71+
formulaRow: number,
72+
formulaCol: number,
73+
headerValues?: string[],
74+
gridRows = 0,
75+
gridCols = 0,
76+
): void => {
77+
for (const id of _highlightedIds) {
78+
const el = document.getElementById(id);
79+
if (el) {
80+
el.removeAttribute("data-formula-ref");
81+
}
82+
}
83+
_highlightedIds = [];
84+
for (const { el, attr } of _highlightedAxes) el.removeAttribute(attr);
85+
_highlightedAxes = [];
86+
87+
if (!formulaValue.startsWith("=")) return;
88+
89+
const refs = extractRefs(formulaValue.slice(1), headerValues, gridRows, gridCols);
90+
const highlightedCols = new Set<number>();
91+
const highlightedRows = new Set<number>();
92+
93+
for (const [r, c] of refs) {
94+
if (r === formulaRow && c === formulaCol) continue;
95+
const el = document.getElementById(`${r}-${c}`);
96+
if (el) {
97+
el.setAttribute("data-formula-ref", "1");
98+
_highlightedIds.push(`${r}-${c}`);
99+
}
100+
highlightedCols.add(c);
101+
highlightedRows.add(r);
102+
}
103+
104+
const headerThs = document.querySelectorAll(".sheet-table thead th");
105+
for (const col of highlightedCols) {
106+
const th = headerThs[col + 1] as HTMLElement | undefined;
107+
if (th) {
108+
th.setAttribute("data-formula-col", "1");
109+
_highlightedAxes.push({ el: th, attr: "data-formula-col" });
110+
}
111+
}
112+
for (const row of highlightedRows) {
113+
const tr = document.querySelector(`.sheet-table tbody tr:nth-child(${row + 1})`);
114+
const axisTd = tr?.querySelector("td:first-child");
115+
if (axisTd) {
116+
axisTd.setAttribute("data-formula-row", "1");
117+
_highlightedAxes.push({ el: axisTd, attr: "data-formula-row" });
118+
}
119+
}
120+
};
121+
122+
export const clearFormulaHighlights = (): void => {
123+
for (const id of _highlightedIds) {
124+
document.getElementById(id)?.removeAttribute("data-formula-ref");
125+
}
126+
_highlightedIds = [];
127+
for (const { el, attr } of _highlightedAxes) el.removeAttribute(attr);
128+
_highlightedAxes = [];
129+
};

‎src/lib/list/index.tsx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,14 @@ const List = (props: Props) => {
172172
dispatch(selectAllCells);
173173
}
174174
if (e.code === "Backspace" || e.code === "Delete") {
175-
dispatch(deleteSelectItems);
176-
onChange && onChange();
175+
// Only delete the selected cell(s) when the user is NOT actively typing
176+
// in an input/textarea (e.g. the fx bar). Checking document.activeElement
177+
// avoids wiping cell values while the user edits text in any input field.
178+
const tag = (document.activeElement as HTMLElement)?.tagName?.toLowerCase();
179+
if (tag !== "input" && tag !== "textarea") {
180+
dispatch(deleteSelectItems);
181+
onChange && onChange();
182+
}
177183
} else if ((e.ctrlKey || e.metaKey) && e.code === "KeyC") {
178184
e.preventDefault();
179185
copyToClipBoard();
@@ -225,7 +231,9 @@ const List = (props: Props) => {
225231

226232
return (
227233
<div onKeyDown={handleKeyDown} className="sheet-table" data-testid="sheet-table" tabIndex={0}>
228-
{!hideTools && <Tools changeStyle={changeStyle} onChange={onChange} />}
234+
{!hideTools && (
235+
<Tools changeStyle={changeStyle} onChange={onChange} headerValues={headerValues} />
236+
)}
229237
<div
230238
className="sheet-table-table-container"
231239
ref={parentDivRef}

‎src/lib/list/input.tsx‎

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
selectOneCell,
99
} from "../reducer";
1010
import { getCalculatedVal } from "./utils";
11+
import { updateFormulaHighlights, clearFormulaHighlights } from "./formula-edit-state";
1112

1213
interface Prop {
1314
i: number;
@@ -27,17 +28,22 @@ const detectLeftButton = (evt: any) => {
2728
const Input = (props: Prop) => {
2829
const { i, j, onChange, headerValues } = props;
2930
const [editMode, setEdit] = useState(false);
30-
// Track focus in a ref — it only affects the value selector, not JSX output,
31-
// so it doesn't need to be state that triggers its own re-render cycle.
32-
const focusRef = useRef(false);
3331
const { dispatch } = store;
3432

33+
// Ref so the selector below can always read the current editMode without
34+
// closing over a stale value. Updated synchronously before any selector runs.
35+
const editModeRef = useRef(false);
36+
3537
// O(1) selected check via the pre-computed Set in the store.
3638
const selected = useAppSelector(store, () => store.getSelectedSet().has(`${i},${j}`));
3739

40+
// Run getCalculatedVal inside the selector so that when any referenced cell
41+
// changes, the selector returns a new value and triggers a re-render.
42+
// editModeRef (not editMode state) is read here to avoid stale closures —
43+
// the ref is always current because setEdit + editModeRef are updated together.
3844
const value = useAppSelector(store, (state) => {
3945
const val = state.data[i][j].value;
40-
if (!focusRef.current && val && val.toString().trim().startsWith("=")) {
46+
if (!editModeRef.current && val && val.toString().trim().startsWith("=")) {
4147
return getCalculatedVal(val, state.data, headerValues);
4248
}
4349
return val;
@@ -56,6 +62,7 @@ const Input = (props: Prop) => {
5662
const change = useCallback(
5763
(e: ChangeEvent<HTMLInputElement>) => {
5864
if (value !== e.target.value) {
65+
editModeRef.current = true;
5966
setEdit(true);
6067
dispatch(changeData, { payload: { value: e.target.value || "", i, j } });
6168
onChange && onChange(i, j, e.target.value);
@@ -167,7 +174,10 @@ const Input = (props: Prop) => {
167174
if (e.ctrlKey || e.metaKey || e.shiftKey) {
168175
dispatch(selectCells, { payload: { i, j } });
169176
} else {
170-
selected && setEdit(true);
177+
if (selected) {
178+
editModeRef.current = true;
179+
setEdit(true);
180+
}
171181
setSelected();
172182
}
173183
} else {
@@ -187,9 +197,6 @@ const Input = (props: Prop) => {
187197
[i, j, dispatch],
188198
);
189199

190-
// No useMemo wrapper — Input only re-renders when one of its store slices
191-
// (selected, value, styles, type) or local state (editMode) changes, so
192-
// the memo was paying cost for zero benefit.
193200
return (
194201
<input
195202
key={`${i}-${j}-${type}`}
@@ -199,18 +206,40 @@ const Input = (props: Prop) => {
199206
style={parsedStyles}
200207
type={type}
201208
onFocus={() => {
202-
focusRef.current = true;
209+
editModeRef.current = true;
203210
}}
204211
onBlur={() => {
205-
focusRef.current = false;
212+
editModeRef.current = false;
206213
setEdit(false);
214+
// Clear formula highlights when leaving a formula cell.
215+
clearFormulaHighlights();
207216
}}
208217
onMouseMoveCapture={onDrag}
209218
onMouseDown={onClick}
210219
className={`input${editMode ? "" : " view_mode"}${selected ? " sheet-selected-td" : ""}`}
211-
onDoubleClick={() => setEdit(true)}
220+
onDoubleClick={() => {
221+
editModeRef.current = true;
222+
setEdit(true);
223+
}}
212224
onKeyDown={keyDown}
213-
onChange={change}
225+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
226+
const newVal = e.target.value;
227+
// Update reference highlights live as the user types a formula.
228+
if (newVal.startsWith("=")) {
229+
const state = store.getState();
230+
updateFormulaHighlights(
231+
newVal,
232+
i,
233+
j,
234+
headerValues,
235+
state.data.length,
236+
state.data[0]?.length ?? 0,
237+
);
238+
} else {
239+
clearFormulaHighlights();
240+
}
241+
change(e);
242+
}}
214243
/>
215244
);
216245
};

0 commit comments

Comments
 (0)