@particle-academy/fancy-sheets 0.6.3 → 0.7.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.
@@ -61,6 +61,8 @@ Takes `SheetData` directly — no workbook wrapper, no tabs, no toolbar:
61
61
  | rowHeight | `number` | `28` | Row height in px |
62
62
  | readOnly | `boolean` | `false` | Disable editing |
63
63
  | contextMenuItems | `ContextMenuItem[] \| (addr) => ContextMenuItem[]` | - | Custom right-click items |
64
+ | highlights | `CellHighlightMap` | - | Consumer-driven cell highlights (address → color/label) |
65
+ | onActiveCellChange | `(addr, cell?) => void` | - | Fires when the active cell changes |
64
66
  | className | `string` | - | Additional CSS classes |
65
67
 
66
68
  ### Spreadsheet.Toolbar
@@ -69,6 +71,7 @@ Takes `SheetData` directly — no workbook wrapper, no tabs, no toolbar:
69
71
  |------|------|-------------|
70
72
  | children | `ReactNode` | Replace default toolbar entirely |
71
73
  | extra | `ReactNode` | Append content after default buttons (before formula bar) |
74
+ | buttons | `ToolbarButton[]` | Which built-in groups to show (default: all). Pass `[]` for only custom `extra`. |
72
75
  | className | `string` | Additional CSS classes |
73
76
 
74
77
  ### SheetWorkbook
@@ -80,6 +83,7 @@ All `Spreadsheet` props plus:
80
83
  | hideToolbar | `boolean` | `false` | Hide the toolbar |
81
84
  | hideTabs | `boolean` | `false` | Hide the sheet tabs |
82
85
  | toolbarExtra | `ReactNode` | - | Extra toolbar content |
86
+ | toolbarButtons | `ToolbarButton[]` | all | Which built-in toolbar groups to show |
83
87
 
84
88
  ### Sheet
85
89
 
@@ -127,6 +131,7 @@ interface CellData {
127
131
  computedValue?: CellValue;
128
132
  format?: CellFormat;
129
133
  comment?: CellComment;
134
+ meta?: Record<string, unknown>; // consumer-defined metadata
130
135
  }
131
136
  ```
132
137
 
@@ -146,6 +151,7 @@ interface CellFormat {
146
151
  borderRight?: string;
147
152
  borderBottom?: string;
148
153
  borderLeft?: string;
154
+ className?: string; // custom CSS class(es) on the cell element
149
155
  }
150
156
  ```
151
157
 
@@ -166,51 +172,350 @@ Comments render as:
166
172
  - A 1px border around the cell in the comment color
167
173
  - A hover tooltip showing author + text
168
174
 
169
- ### Context Menu Items
175
+ ### SpreadsheetContextMenuItem
170
176
 
171
177
  ```ts
172
178
  interface SpreadsheetContextMenuItem {
173
179
  label: string;
174
- onClick: (address: string) => void;
180
+ onClick?: (address: string) => void;
175
181
  disabled?: boolean | ((address: string) => boolean);
176
182
  danger?: boolean;
183
+ items?: SpreadsheetContextMenuItem[]; // nested submenu
177
184
  }
178
185
  ```
179
186
 
180
- Pass as array (static) or callback (dynamic per cell):
187
+ ---
188
+
189
+ ## Custom Toolbar Buttons
190
+
191
+ Use the `extra` prop on `Spreadsheet.Toolbar` (or `toolbarExtra` on `SheetWorkbook`) to inject your own buttons alongside the built-in ones. They appear after a divider at the end of the button bar.
192
+
193
+ ```tsx
194
+ <Spreadsheet data={data} onChange={setData}>
195
+ <Spreadsheet.Toolbar
196
+ extra={
197
+ <>
198
+ <button
199
+ className="rounded bg-green-600 px-2 py-0.5 text-xs text-white"
200
+ onClick={() => addRow("income")}
201
+ >
202
+ + Income
203
+ </button>
204
+ <button
205
+ className="rounded bg-red-500 px-2 py-0.5 text-xs text-white ml-1"
206
+ onClick={() => addRow("expense")}
207
+ >
208
+ + Expense
209
+ </button>
210
+ </>
211
+ }
212
+ />
213
+ <Spreadsheet.Grid />
214
+ </Spreadsheet>
215
+ ```
216
+
217
+ Or via `SheetWorkbook`:
218
+
219
+ ```tsx
220
+ <SheetWorkbook
221
+ data={data}
222
+ onChange={setData}
223
+ toolbarExtra={<button onClick={exportCSV}>Export</button>}
224
+ />
225
+ ```
226
+
227
+ ## Controlling the Toolbar
228
+
229
+ The `buttons` prop on `Spreadsheet.Toolbar` (or `toolbarButtons` on `SheetWorkbook`) controls which built-in button groups are visible.
230
+
231
+ ```ts
232
+ type ToolbarButton = "undo" | "bold" | "align" | "freeze" | "format" | "decimals" | "formulaBar";
233
+ ```
234
+
235
+ ### Show all (default)
236
+
237
+ ```tsx
238
+ <Spreadsheet.Toolbar />
239
+ ```
240
+
241
+ ### Show specific groups only
242
+
243
+ ```tsx
244
+ <Spreadsheet.Toolbar buttons={["undo", "bold", "formulaBar"]} />
245
+ ```
246
+
247
+ ### Show only custom buttons (hide all built-in)
248
+
249
+ ```tsx
250
+ <Spreadsheet.Toolbar buttons={[]} extra={<MyCustomToolbar />} />
251
+ ```
252
+
253
+ ### Via SheetWorkbook
254
+
255
+ ```tsx
256
+ <SheetWorkbook
257
+ toolbarButtons={["undo", "bold", "format", "formulaBar"]}
258
+ toolbarExtra={<button>Custom</button>}
259
+ />
260
+ ```
261
+
262
+ ### Replace the toolbar entirely
263
+
264
+ Pass `children` to `Spreadsheet.Toolbar` to replace the default UI completely. Use `useSpreadsheet()` inside your custom toolbar to access state and actions.
265
+
266
+ ```tsx
267
+ function MyToolbar() {
268
+ const { undo, redo, canUndo, canRedo, selection } = useSpreadsheet();
269
+ return (
270
+ <div className="flex gap-2 p-2 border-b">
271
+ <button onClick={undo} disabled={!canUndo}>Undo</button>
272
+ <button onClick={redo} disabled={!canRedo}>Redo</button>
273
+ <span>Cell: {selection.activeCell}</span>
274
+ </div>
275
+ );
276
+ }
277
+
278
+ <Spreadsheet data={data} onChange={setData}>
279
+ <Spreadsheet.Toolbar>
280
+ <MyToolbar />
281
+ </Spreadsheet.Toolbar>
282
+ <Spreadsheet.Grid />
283
+ </Spreadsheet>
284
+ ```
285
+
286
+ ## Custom Context Menus
287
+
288
+ The `contextMenuItems` prop adds items to the right-click menu after the built-in items (Copy, Paste, Clear, Freeze), separated by a divider.
289
+
290
+ ### Static items
291
+
292
+ ```tsx
293
+ <Spreadsheet
294
+ data={data}
295
+ onChange={setData}
296
+ contextMenuItems={[
297
+ { label: "Highlight yellow", onClick: (addr) => highlight(addr, "#fef08a") },
298
+ { label: "Clear formatting", onClick: (addr) => clearFormat(addr) },
299
+ ]}
300
+ >
301
+ ```
302
+
303
+ ### Dynamic items (context-aware)
304
+
305
+ Pass a callback to return different items based on the active cell:
306
+
307
+ ```tsx
308
+ <Spreadsheet
309
+ data={data}
310
+ onChange={setData}
311
+ contextMenuItems={(addr) => {
312
+ const cell = activeSheet.cells[addr];
313
+ const row = parseInt(addr.replace(/[A-Z]+/, ""), 10);
314
+
315
+ const items: SpreadsheetContextMenuItem[] = [];
316
+
317
+ // Comment actions depend on whether cell has a comment
318
+ if (cell?.comment) {
319
+ items.push({ label: "Edit Comment", onClick: (a) => openEditor(a) });
320
+ items.push({ label: "Delete Comment", danger: true, onClick: (a) => deleteComment(a) });
321
+ } else {
322
+ items.push({ label: "Add Comment", onClick: (a) => openEditor(a) });
323
+ }
324
+
325
+ // Row actions only for data rows
326
+ if (row >= 2) {
327
+ items.push({ label: "Delete Row", danger: true, onClick: (a) => deleteRow(a) });
328
+ }
329
+
330
+ return items;
331
+ }}
332
+ >
333
+ ```
334
+
335
+ ### Nested submenus
336
+
337
+ Add an `items` array to group actions into submenus:
338
+
339
+ ```tsx
340
+ contextMenuItems={(addr) => [
341
+ {
342
+ label: "Comments",
343
+ items: [
344
+ { label: "Add Comment", onClick: (a) => openEditor(a) },
345
+ ],
346
+ },
347
+ {
348
+ label: "Row Actions",
349
+ items: [
350
+ { label: "Mark as Paid", onClick: (a) => markPaid(a) },
351
+ { label: "Duplicate Row", onClick: (a) => duplicateRow(a) },
352
+ { label: "Delete Row", danger: true, onClick: (a) => deleteRow(a) },
353
+ ],
354
+ },
355
+ ]}
356
+ ```
357
+
358
+ Submenus nest arbitrarily deep — each item with `items` renders as a hover-to-open submenu with a chevron indicator.
359
+
360
+ ## Cell Metadata
361
+
362
+ The `meta` field on `CellData` stores arbitrary consumer-defined data. The package preserves it through edits but never reads it — consumers use it to build domain-specific features like grouping, tagging, or variable bindings.
363
+
364
+ ```tsx
365
+ s.cells = {
366
+ A1: { value: "Revenue", meta: { group: "rev", role: "label" } },
367
+ B1: { value: 50000, meta: { group: "rev", role: "variable" } },
368
+ };
369
+ ```
370
+
371
+ ## Custom CSS Classes
372
+
373
+ The `className` field on `CellFormat` applies custom CSS classes to individual cell elements. Use it for Tailwind utilities, conditional styling, animations, or any CSS your app needs.
374
+
375
+ ```tsx
376
+ s.cells = {
377
+ A1: { value: "Header", format: { className: "font-semibold tracking-wide" } },
378
+ B1: { value: 42, format: { className: "tabular-nums text-right" } },
379
+ C1: { value: "Alert", format: { className: "animate-pulse bg-red-100" } },
380
+ };
381
+ ```
382
+
383
+ Classes are merged after the built-in cell classes via `cn()`, so Tailwind utilities work and can override defaults.
384
+
385
+ ## Cell Highlights
386
+
387
+ The `highlights` prop renders visual overlays on cells independent of selection and formatting — colored outlines, background tints, and optional label badges.
388
+
389
+ ```ts
390
+ interface CellHighlight {
391
+ color: string; // outline color (CSS)
392
+ backgroundColor?: string; // tint (auto-derived at 10% opacity from hex if omitted)
393
+ label?: string; // small badge in top-left corner
394
+ }
395
+
396
+ type CellHighlightMap = Record<string, CellHighlight>;
397
+ ```
398
+
399
+ ### Static highlights
181
400
 
182
401
  ```tsx
183
- // Static
184
- <Spreadsheet contextMenuItems={[
185
- { label: "Highlight", onClick: (addr) => highlight(addr) },
186
- ]}>
402
+ <Sheet
403
+ data={sheet}
404
+ onChange={setSheet}
405
+ highlights={{
406
+ A1: { color: "#8b5cf6", label: "src" },
407
+ B1: { color: "#8b5cf6", label: "dst" },
408
+ }}
409
+ />
410
+ ```
411
+
412
+ ### Reactive highlights (grouping pattern)
413
+
414
+ Use `onActiveCellChange` + `meta` to highlight related cells when the user clicks:
187
415
 
188
- // Dynamic (context-aware)
189
- <Spreadsheet contextMenuItems={(addr) => {
190
- const cell = sheet.cells[addr];
191
- return cell?.comment
192
- ? [{ label: "Edit Comment", onClick: ... }, { label: "Delete Comment", danger: true, onClick: ... }]
193
- : [{ label: "Add Comment", onClick: ... }];
194
- }}>
416
+ ```tsx
417
+ const [activeAddr, setActiveAddr] = useState("A1");
418
+
419
+ const highlights = useMemo<CellHighlightMap>(() => {
420
+ const group = (sheet.cells[activeAddr]?.meta as any)?.group;
421
+ if (!group) return {};
422
+ const map: CellHighlightMap = {};
423
+ for (const [addr, cell] of Object.entries(sheet.cells)) {
424
+ if ((cell.meta as any)?.group === group) {
425
+ map[addr] = {
426
+ color: "#8b5cf6",
427
+ label: (cell.meta as any)?.role,
428
+ };
429
+ }
430
+ }
431
+ return map;
432
+ }, [activeAddr, sheet.cells]);
433
+
434
+ <Sheet
435
+ data={sheet}
436
+ onChange={setSheet}
437
+ highlights={highlights}
438
+ onActiveCellChange={(addr) => setActiveAddr(addr)}
439
+ />
195
440
  ```
196
441
 
197
- Items appear after a separator below the built-in items (Copy, Paste, Clear, Freeze).
442
+ Click a cell tagged with `meta: { group: "rev" }` and all cells in that group highlight with a purple outline, tinted background, and role badges ("var", "lbl").
198
443
 
199
444
  ## Custom Formulas
200
445
 
446
+ Register custom functions that users can call in cell formulas with `=FUNCTION_NAME(...)`.
447
+
201
448
  ```tsx
202
449
  import { registerFunction } from "@particle-academy/fancy-sheets";
203
450
  import type { FormulaRangeFunction } from "@particle-academy/fancy-sheets";
451
+ ```
452
+
453
+ ### Basic function
204
454
 
205
- const myFormula: FormulaRangeFunction = (args) => {
455
+ ```tsx
456
+ registerFunction("PRIORITY", (args) => {
206
457
  const value = Number(args[0]?.[0] ?? 0);
207
458
  return value > 100 ? "High" : "Low";
208
- };
459
+ });
460
+
461
+ // In a cell: =PRIORITY(A1)
462
+ ```
463
+
464
+ ### Function with multiple arguments
209
465
 
210
- registerFunction("PRIORITY", myFormula);
211
- // Usage in cells: =PRIORITY(A1)
466
+ `args` is a 2D array — each argument may be a single value or a range of values:
467
+
468
+ ```tsx
469
+ registerFunction("BUDGET_STATUS", (args) => {
470
+ const spent = Number(args[0]?.[0] ?? 0); // first arg: single cell
471
+ const budget = Number(args[1]?.[0] ?? 0); // second arg: single cell
472
+ if (!budget) return "N/A";
473
+ const ratio = spent / budget;
474
+ if (ratio > 1) return "Over Budget";
475
+ if (ratio > 0.9) return "Near Limit";
476
+ return "On Track";
477
+ });
478
+
479
+ // In a cell: =BUDGET_STATUS(C4, B4)
480
+ ```
481
+
482
+ ### Function that operates on ranges
483
+
484
+ When a range like `A1:A10` is passed, `args[n]` contains all values in the range as a flat array:
485
+
486
+ ```tsx
487
+ registerFunction("WEIGHTED_AVG", (args) => {
488
+ const values = args[0] ?? []; // =WEIGHTED_AVG(B2:B10, C2:C10)
489
+ const weights = args[1] ?? [];
490
+ let sumProduct = 0, sumWeights = 0;
491
+ for (let i = 0; i < values.length; i++) {
492
+ const v = Number(values[i] ?? 0);
493
+ const w = Number(weights[i] ?? 0);
494
+ sumProduct += v * w;
495
+ sumWeights += w;
496
+ }
497
+ return sumWeights === 0 ? 0 : sumProduct / sumWeights;
498
+ });
499
+
500
+ // In a cell: =WEIGHTED_AVG(B2:B10, C2:C10)
212
501
  ```
213
502
 
503
+ ### Type signature
504
+
505
+ ```ts
506
+ type FormulaRangeFunction = (args: CellValue[][]) => CellValue;
507
+ // CellValue = string | number | boolean | null
508
+ ```
509
+
510
+ - `args[0]` = first argument's values, `args[1]` = second, etc.
511
+ - Single-cell arguments are `[value]` (array of one). Ranges are flat arrays.
512
+ - Return a `CellValue`. Return a string starting with `#` for errors (e.g., `"#VALUE!"`).
513
+ - Call `registerFunction` at module scope (before render). It's a global side-effect.
514
+
515
+ ### Built-in functions (80+)
516
+
517
+ See [Formulas](./formulas.md) for the complete list: SUM, AVERAGE, IF, VLOOKUP, SUMIF, TODAY, CONCAT, and more.
518
+
214
519
  ## Helpers
215
520
 
216
521
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/fancy-sheets",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "Spreadsheet editor with formula engine, multi-sheet tabs, and full cell editing",
5
5
  "repository": {
6
6
  "type": "git",