@particle-academy/fancy-sheets 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,482 @@
1
+ # @particle-academy/fancy-sheets
2
+
3
+ Spreadsheet editor with formula engine, multi-sheet tabs, and full cell editing. Part of the `@particle-academy` component ecosystem.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # npm
9
+ npm install @particle-academy/fancy-sheets
10
+
11
+ # pnpm
12
+ pnpm add @particle-academy/fancy-sheets
13
+
14
+ # yarn
15
+ yarn add @particle-academy/fancy-sheets
16
+ ```
17
+
18
+ **Peer dependencies:** `react >= 18`, `react-dom >= 18`, `@particle-academy/react-fancy >= 1.5`
19
+
20
+ ## Usage
21
+
22
+ ```css
23
+ @import "tailwindcss";
24
+ @import "@particle-academy/fancy-sheets/styles.css";
25
+ @source "../node_modules/@particle-academy/fancy-sheets/dist/**/*.js";
26
+ ```
27
+
28
+ ```tsx
29
+ import { Spreadsheet, createEmptyWorkbook } from "@particle-academy/fancy-sheets";
30
+ import "@particle-academy/fancy-sheets/styles.css";
31
+
32
+ function App() {
33
+ const [data, setData] = useState(createEmptyWorkbook());
34
+
35
+ return (
36
+ <div style={{ height: 500 }}>
37
+ <Spreadsheet data={data} onChange={setData}>
38
+ <Spreadsheet.Toolbar />
39
+ <Spreadsheet.Grid />
40
+ <Spreadsheet.SheetTabs />
41
+ </Spreadsheet>
42
+ </div>
43
+ );
44
+ }
45
+ ```
46
+
47
+ The spreadsheet fills its container — set a fixed height on the parent element.
48
+
49
+ ## Commands
50
+
51
+ ```bash
52
+ pnpm --filter @particle-academy/fancy-sheets build # Build with tsup (ESM + CJS + DTS)
53
+ pnpm --filter @particle-academy/fancy-sheets dev # Watch mode
54
+ pnpm --filter @particle-academy/fancy-sheets lint # Type-check (tsc --noEmit)
55
+ pnpm --filter @particle-academy/fancy-sheets clean # Remove dist/
56
+ ```
57
+
58
+ ## Component API
59
+
60
+ ### Compound Components
61
+
62
+ | Component | Description |
63
+ |-----------|-------------|
64
+ | `Spreadsheet` | Root wrapper — state management, context provider |
65
+ | `Spreadsheet.Toolbar` | Undo/redo, bold/italic/align buttons, formula bar |
66
+ | `Spreadsheet.Grid` | Editable cell grid with headers, selection, editing |
67
+ | `Spreadsheet.SheetTabs` | Multi-sheet tab bar with add/rename/delete |
68
+
69
+ ### Spreadsheet Props
70
+
71
+ ```ts
72
+ interface SpreadsheetProps {
73
+ children: ReactNode;
74
+ className?: string;
75
+ data?: WorkbookData; // Controlled workbook data
76
+ defaultData?: WorkbookData; // Uncontrolled initial data
77
+ onChange?: (data: WorkbookData) => void;
78
+ columnCount?: number; // Default: 26 (A-Z)
79
+ rowCount?: number; // Default: 100
80
+ defaultColumnWidth?: number; // Default: 100px
81
+ rowHeight?: number; // Default: 28px
82
+ readOnly?: boolean; // Default: false
83
+ }
84
+ ```
85
+
86
+ ### useSpreadsheet Hook
87
+
88
+ Access the spreadsheet context from custom components:
89
+
90
+ ```tsx
91
+ import { useSpreadsheet } from "@particle-academy/fancy-sheets";
92
+
93
+ function CustomToolbar() {
94
+ const { selection, setCellValue, setCellFormat, undo, redo } = useSpreadsheet();
95
+ // Build custom toolbar buttons
96
+ }
97
+ ```
98
+
99
+ **Context value:**
100
+
101
+ | Property / Method | Description |
102
+ |-------------------|-------------|
103
+ | `workbook` | Current WorkbookData |
104
+ | `activeSheet` | The currently active SheetData |
105
+ | `selection` | Current SelectionState (activeCell, ranges) |
106
+ | `editingCell` | Address of cell being edited (null if not editing) |
107
+ | `editValue` | Current value in the cell editor |
108
+ | `setCellValue(addr, value)` | Set a cell's value (triggers formula recalc) |
109
+ | `setCellFormat(addrs, format)` | Apply formatting to cells |
110
+ | `setSelection(cell)` | Select a single cell |
111
+ | `extendSelection(cell)` | Extend selection range (shift+click) |
112
+ | `addSelection(cell)` | Add to selection (ctrl+click) |
113
+ | `navigate(direction, extend?)` | Move active cell (arrow keys) |
114
+ | `startEdit(value?)` | Enter edit mode |
115
+ | `confirmEdit()` | Confirm edit and move down |
116
+ | `cancelEdit()` | Cancel edit (Escape) |
117
+ | `resizeColumn(col, width)` | Set column width |
118
+ | `addSheet()` | Add a new sheet |
119
+ | `renameSheet(id, name)` | Rename a sheet |
120
+ | `deleteSheet(id)` | Delete a sheet |
121
+ | `setActiveSheet(id)` | Switch active sheet |
122
+ | `undo()` / `redo()` | Undo/redo (50-step history) |
123
+ | `canUndo` / `canRedo` | Whether undo/redo is available |
124
+ | `getColumnWidth(col)` | Get column width in px |
125
+ | `isCellSelected(addr)` | Check if cell is in selection |
126
+ | `isCellActive(addr)` | Check if cell is the active cell |
127
+
128
+ ## Data Model
129
+
130
+ ### WorkbookData
131
+
132
+ ```ts
133
+ interface WorkbookData {
134
+ sheets: SheetData[];
135
+ activeSheetId: string;
136
+ }
137
+ ```
138
+
139
+ ### SheetData
140
+
141
+ ```ts
142
+ interface SheetData {
143
+ id: string;
144
+ name: string;
145
+ cells: CellMap; // Record<CellAddress, CellData>
146
+ columnWidths: Record<number, number>; // Column width overrides
147
+ mergedRegions: MergedRegion[];
148
+ columnFilters: Record<number, string>;
149
+ sortColumn?: number;
150
+ sortDirection?: "asc" | "desc";
151
+ frozenRows: number;
152
+ frozenCols: number;
153
+ }
154
+ ```
155
+
156
+ ### CellData
157
+
158
+ ```ts
159
+ interface CellData {
160
+ value: CellValue; // string | number | boolean | null
161
+ formula?: string; // Without leading "=", e.g. "SUM(A1:A5)"
162
+ computedValue?: CellValue; // Result of formula evaluation
163
+ format?: CellFormat; // { bold?, italic?, textAlign? }
164
+ }
165
+ ```
166
+
167
+ ### Helpers
168
+
169
+ ```tsx
170
+ import { createEmptyWorkbook, createEmptySheet } from "@particle-academy/fancy-sheets";
171
+
172
+ const workbook = createEmptyWorkbook(); // One empty sheet
173
+ const sheet = createEmptySheet("my-id", "My Sheet"); // Custom sheet
174
+ ```
175
+
176
+ ## Formula Engine
177
+
178
+ Type `=` in any cell to enter a formula. The engine supports cell references, ranges, operators, and functions.
179
+
180
+ ### Cell References
181
+
182
+ | Syntax | Example | Description |
183
+ |--------|---------|-------------|
184
+ | Cell ref | `=A1` | Single cell reference |
185
+ | Range | `=A1:B5` | Rectangular range |
186
+ | Arithmetic | `=A1+B1*2` | Operators: `+`, `-`, `*`, `/`, `^` |
187
+ | Comparison | `=A1>10` | Operators: `=`, `<>`, `<`, `>`, `<=`, `>=` |
188
+ | Concatenation | `=A1&" "&B1` | String join with `&` |
189
+
190
+ ### Built-in Functions (80+)
191
+
192
+ **Math (25):**
193
+
194
+ | Function | Example | Description |
195
+ |----------|---------|-------------|
196
+ | `SUM` | `=SUM(A1:A10)` | Sum of values |
197
+ | `AVERAGE` | `=AVERAGE(B1:B5)` | Mean of values |
198
+ | `MEDIAN` | `=MEDIAN(A1:A10)` | Median value |
199
+ | `MIN` | `=MIN(C1:C10)` | Minimum value |
200
+ | `MAX` | `=MAX(C1:C10)` | Maximum value |
201
+ | `COUNT` | `=COUNT(A1:A20)` | Count of numeric values |
202
+ | `PRODUCT` | `=PRODUCT(A1:A5)` | Multiply all values |
203
+ | `ROUND` | `=ROUND(A1, 2)` | Round to N decimals |
204
+ | `ABS` | `=ABS(A1)` | Absolute value |
205
+ | `SQRT` | `=SQRT(A1)` | Square root |
206
+ | `POWER` | `=POWER(2,10)` | Exponentiation |
207
+ | `MOD` | `=MOD(10,3)` | Remainder |
208
+ | `INT` | `=INT(3.7)` | Round down to integer |
209
+ | `TRUNC` | `=TRUNC(3.789,1)` | Truncate decimals |
210
+ | `FLOOR` | `=FLOOR(2.7,1)` | Round down to multiple |
211
+ | `CEILING` | `=CEILING(2.1,1)` | Round up to multiple |
212
+ | `SIGN` | `=SIGN(-5)` | Returns -1, 0, or 1 |
213
+ | `FACT` | `=FACT(5)` | Factorial (120) |
214
+ | `PI` | `=PI()` | 3.14159... |
215
+ | `EXP` | `=EXP(1)` | e^n |
216
+ | `LN` | `=LN(10)` | Natural logarithm |
217
+ | `LOG` | `=LOG(100,10)` | Logarithm (default base 10) |
218
+ | `LOG10` | `=LOG10(1000)` | Base-10 logarithm |
219
+ | `RAND` | `=RAND()` | Random 0-1 |
220
+ | `RANDBETWEEN` | `=RANDBETWEEN(1,100)` | Random integer |
221
+
222
+ **Text (19):**
223
+
224
+ | Function | Example | Description |
225
+ |----------|---------|-------------|
226
+ | `UPPER` | `=UPPER(A1)` | Uppercase |
227
+ | `LOWER` | `=LOWER(A1)` | Lowercase |
228
+ | `PROPER` | `=PROPER("hello world")` | Title Case |
229
+ | `LEN` | `=LEN(A1)` | String length |
230
+ | `TRIM` | `=TRIM(A1)` | Remove whitespace |
231
+ | `LEFT` | `=LEFT(A1,3)` | First N characters |
232
+ | `RIGHT` | `=RIGHT(A1,3)` | Last N characters |
233
+ | `MID` | `=MID(A1,2,3)` | Substring from position |
234
+ | `FIND` | `=FIND("x",A1)` | Case-sensitive position |
235
+ | `SEARCH` | `=SEARCH("x",A1)` | Case-insensitive position |
236
+ | `SUBSTITUTE` | `=SUBSTITUTE(A1,"old","new")` | Replace text |
237
+ | `REPLACE` | `=REPLACE(A1,2,3,"xyz")` | Replace by position |
238
+ | `CONCAT` | `=CONCAT(A1,B1)` | Join values |
239
+ | `REPT` | `=REPT("*",5)` | Repeat string |
240
+ | `EXACT` | `=EXACT(A1,B1)` | Case-sensitive compare |
241
+ | `VALUE` | `=VALUE("42")` | Text to number |
242
+ | `TEXT` | `=TEXT(0.5,"0%")` | Format number as text |
243
+ | `CHAR` | `=CHAR(65)` | Character from code |
244
+ | `CODE` | `=CODE("A")` | Code of first character |
245
+
246
+ **Logic (8):**
247
+
248
+ | Function | Example | Description |
249
+ |----------|---------|-------------|
250
+ | `IF` | `=IF(A1>10,"High","Low")` | Conditional value |
251
+ | `AND` | `=AND(A1>0,B1>0)` | All conditions true |
252
+ | `OR` | `=OR(A1>0,B1>0)` | Any condition true |
253
+ | `NOT` | `=NOT(A1)` | Negate boolean |
254
+ | `IFERROR` | `=IFERROR(A1/B1,0)` | Fallback on error |
255
+ | `IFBLANK` | `=IFBLANK(A1,"N/A")` | Fallback if empty |
256
+ | `SWITCH` | `=SWITCH(A1,1,"One",2,"Two")` | Multi-case conditional |
257
+ | `CHOOSE` | `=CHOOSE(2,"a","b","c")` | Select by index |
258
+
259
+ **Conditional Aggregates (8):**
260
+
261
+ | Function | Example | Description |
262
+ |----------|---------|-------------|
263
+ | `SUMIF` | `=SUMIF(A1:A10,">5",B1:B10)` | Conditional sum |
264
+ | `SUMIFS` | `=SUMIFS(C1:C10,A1:A10,">5",B1:B10,"<10")` | Multi-criteria sum |
265
+ | `COUNTIF` | `=COUNTIF(A1:A10,"Yes")` | Conditional count |
266
+ | `COUNTIFS` | `=COUNTIFS(A1:A10,">5",B1:B10,"<10")` | Multi-criteria count |
267
+ | `AVERAGEIF` | `=AVERAGEIF(A1:A10,">0")` | Conditional average |
268
+ | `AVERAGEIFS` | `=AVERAGEIFS(C1:C10,A1:A10,">5")` | Multi-criteria average |
269
+ | `MINIFS` | `=MINIFS(C1:C10,A1:A10,">5")` | Conditional min |
270
+ | `MAXIFS` | `=MAXIFS(C1:C10,A1:A10,">5")` | Conditional max |
271
+
272
+ **Lookup (8):**
273
+
274
+ | Function | Example | Description |
275
+ |----------|---------|-------------|
276
+ | `VLOOKUP` | `=VLOOKUP(key,A1:C10,3)` | Vertical lookup |
277
+ | `HLOOKUP` | `=HLOOKUP(key,A1:Z3,2)` | Horizontal lookup |
278
+ | `INDEX` | `=INDEX(A1:A10,3)` | Value at position |
279
+ | `MATCH` | `=MATCH("x",A1:A10)` | Find position of value |
280
+ | `ROWS` | `=ROWS(A1:A10)` | Count rows in range |
281
+ | `COLUMNS` | `=COLUMNS(A1:C1)` | Count columns in range |
282
+ | `ROW` | `=ROW(A5)` | Row number |
283
+ | `COLUMN` | `=COLUMN(C1)` | Column number |
284
+
285
+ **Date/Time (12):**
286
+
287
+ | Function | Example | Description |
288
+ |----------|---------|-------------|
289
+ | `TODAY` | `=TODAY()` | Current date (serial) |
290
+ | `NOW` | `=NOW()` | Current date+time (serial) |
291
+ | `DATE` | `=DATE(2024,6,15)` | Create date |
292
+ | `YEAR` | `=YEAR(A1)` | Extract year |
293
+ | `MONTH` | `=MONTH(A1)` | Extract month |
294
+ | `DAY` | `=DAY(A1)` | Extract day |
295
+ | `HOUR` | `=HOUR(A1)` | Extract hour |
296
+ | `MINUTE` | `=MINUTE(A1)` | Extract minute |
297
+ | `SECOND` | `=SECOND(A1)` | Extract second |
298
+ | `WEEKDAY` | `=WEEKDAY(A1)` | Day of week (1-7) |
299
+ | `DATEDIF` | `=DATEDIF(A1,B1,"M")` | Date difference |
300
+ | `EDATE` | `=EDATE(A1,3)` | Date + N months |
301
+
302
+ **Info (6):**
303
+
304
+ | Function | Example | Description |
305
+ |----------|---------|-------------|
306
+ | `ISBLANK` | `=ISBLANK(A1)` | Is empty |
307
+ | `ISNUMBER` | `=ISNUMBER(A1)` | Is numeric |
308
+ | `ISTEXT` | `=ISTEXT(A1)` | Is text |
309
+ | `ISERROR` | `=ISERROR(A1)` | Is error value |
310
+ | `ISLOGICAL` | `=ISLOGICAL(A1)` | Is boolean |
311
+ | `TYPE` | `=TYPE(A1)` | Type code (1=num, 2=text, 4=bool, 16=error) |
312
+
313
+ ### Custom Functions
314
+
315
+ Register custom formula functions:
316
+
317
+ ```tsx
318
+ import { registerFunction } from "@particle-academy/fancy-sheets";
319
+
320
+ registerFunction("DOUBLE", (args) => {
321
+ const val = args.flat()[0];
322
+ return typeof val === "number" ? val * 2 : "#VALUE!";
323
+ });
324
+ // Now use =DOUBLE(A1) in any cell
325
+ ```
326
+
327
+ ### Error Values
328
+
329
+ | Error | Cause |
330
+ |-------|-------|
331
+ | `#ERROR!` | Invalid formula syntax |
332
+ | `#VALUE!` | Wrong value type for operation |
333
+ | `#DIV/0!` | Division by zero |
334
+ | `#NAME?` | Unknown function name |
335
+ | `#CIRC!` | Circular reference detected |
336
+
337
+ ### Dependency Tracking
338
+
339
+ The formula engine builds a dependency graph and recalculates in topological order. When cell A1 changes, all cells that reference A1 (directly or transitively) are automatically recalculated. Circular references are detected and marked with `#CIRC!`.
340
+
341
+ ## Features
342
+
343
+ ### Editing
344
+ - Click to select, double-click or type to start editing
345
+ - Enter confirms and moves down, Tab moves right
346
+ - Escape cancels edit
347
+ - Delete/Backspace clears cell
348
+ - Formula bar shows formula for selected cell
349
+
350
+ ### Navigation
351
+ - Arrow keys move between cells
352
+ - Shift+Arrow extends selection range
353
+ - Tab/Shift+Tab moves right/left
354
+ - Ctrl+Z undo, Ctrl+Y redo
355
+
356
+ ### Selection
357
+ - Click to select single cell
358
+ - Shift+Click to select range
359
+ - Ctrl+Click for multi-select
360
+ - Visual blue overlay on selected ranges
361
+
362
+ ### Formatting
363
+ - Bold (B), Italic (I) toggle buttons
364
+ - Text align: left, center, right
365
+ - Applied via toolbar or programmatically
366
+
367
+ ### Clipboard
368
+ - Ctrl+C copies selected range as TSV
369
+ - Ctrl+V pastes TSV data starting at active cell
370
+ - Works with external data from Excel/Google Sheets
371
+
372
+ ### CSV Support
373
+
374
+ ```tsx
375
+ import { parseCSV, csvToWorkbook, workbookToCSV } from "@particle-academy/fancy-sheets";
376
+
377
+ // Import
378
+ const workbook = csvToWorkbook(csvString, "Imported Data");
379
+
380
+ // Export
381
+ const csv = workbookToCSV(workbook);
382
+ ```
383
+
384
+ ### Multi-Sheet
385
+ - Tab bar at bottom shows all sheets
386
+ - Click to switch, double-click to rename
387
+ - "+" button adds new sheet
388
+ - Delete button removes active sheet (minimum 1)
389
+
390
+ ### Column Resize
391
+ - Drag column header borders to resize
392
+ - Minimum width: 30px
393
+
394
+ ### Undo/Redo
395
+ - 50-step history
396
+ - Every cell edit, format change pushes to undo stack
397
+ - Ctrl+Z / Ctrl+Y keyboard shortcuts
398
+
399
+ ## Customization
400
+
401
+ All components render `data-fancy-sheets-*` attributes:
402
+
403
+ | Attribute | Element |
404
+ |-----------|---------|
405
+ | `data-fancy-sheets` | Root container |
406
+ | `data-fancy-sheets-toolbar` | Toolbar area |
407
+ | `data-fancy-sheets-formula-bar` | Formula bar |
408
+ | `data-fancy-sheets-grid` | Grid container |
409
+ | `data-fancy-sheets-column-headers` | Column header row |
410
+ | `data-fancy-sheets-row-header` | Row number cell |
411
+ | `data-fancy-sheets-cell` | Individual cell |
412
+ | `data-fancy-sheets-cell-editor` | Inline edit input |
413
+ | `data-fancy-sheets-selection` | Selection overlay |
414
+ | `data-fancy-sheets-resize-handle` | Column resize handle |
415
+ | `data-fancy-sheets-tabs` | Sheet tab bar |
416
+
417
+ ## Architecture
418
+
419
+ ```
420
+ src/
421
+ ├── types/
422
+ │ ├── cell.ts # CellValue, CellData, CellFormat
423
+ │ ├── sheet.ts # SheetData, WorkbookData, helpers
424
+ │ ├── selection.ts # CellRange, SelectionState
425
+ │ └── formula.ts # FormulaToken, FormulaASTNode
426
+ ├── engine/
427
+ │ ├── cell-utils.ts # Address parsing (A1 <-> row/col)
428
+ │ ├── clipboard.ts # TSV serialize/parse
429
+ │ ├── csv.ts # CSV import/export
430
+ │ ├── sorting.ts # Column sort
431
+ │ ├── history.ts # Undo/redo stack
432
+ │ └── formula/
433
+ │ ├── lexer.ts # Tokenize formula strings
434
+ │ ├── parser.ts # Recursive descent -> AST
435
+ │ ├── evaluator.ts # Evaluate AST with cell lookups
436
+ │ ├── references.ts # Extract cell refs from AST
437
+ │ ├── dependency-graph.ts # Dep tracking, circular detection
438
+ │ └── functions/
439
+ │ ├── registry.ts # Function registry
440
+ │ ├── math.ts # SUM, AVERAGE, MIN, MAX, etc.
441
+ │ ├── text.ts # UPPER, LOWER, LEN, TRIM, CONCAT
442
+ │ └── logic.ts # IF, AND, OR, NOT
443
+ ├── hooks/
444
+ │ └── use-spreadsheet-store.ts # Central reducer + actions
445
+ ├── components/
446
+ │ ├── Spreadsheet/ # Root compound component
447
+ │ ├── Toolbar/ # Format buttons + formula bar
448
+ │ ├── Grid/ # Cell grid, editor, selection, resize
449
+ │ └── SheetTabs/ # Multi-sheet tab bar
450
+ ├── index.ts # Public API
451
+ └── styles.css # Base styles
452
+ ```
453
+
454
+ ## Demo
455
+
456
+ Demo page at `/react-demos/spreadsheet` in the monorepo with pre-populated product catalog data and formulas.
457
+
458
+ ---
459
+
460
+ ## Agent Guidelines
461
+
462
+ ### Component Pattern
463
+ - Same compound component pattern as react-fancy: `Object.assign(Root, { Toolbar, Grid, SheetTabs })`
464
+ - Context via `SpreadsheetContext` + `useSpreadsheet()` hook
465
+ - State managed by `useReducer` with action-based mutations
466
+
467
+ ### Formula Engine
468
+ - Pure functions in `engine/formula/` — no React imports
469
+ - Lexer → Parser → Evaluator pipeline
470
+ - Dependency graph rebuilt on cell changes, topological sort for recalc order
471
+ - Functions registered via side-effect imports (listed in `sideEffects` in package.json)
472
+
473
+ ### Data Model
474
+ - Sparse cell map (`Record<CellAddress, CellData>`) — only stores non-empty cells
475
+ - Formulas stored as strings, computed values cached in `computedValue`
476
+ - Undo/redo via workbook snapshot stack (max 50)
477
+
478
+ ### Build
479
+ - tsup: ESM, CJS, DTS
480
+ - External: react, react-dom, @particle-academy/react-fancy
481
+ - Zero other dependencies
482
+ - Verify with `npm run build` from monorepo root