@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.
- package/dist/index.cjs +46 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +33 -4
- package/dist/index.d.ts +33 -4
- package/dist/index.js +46 -6
- package/dist/index.js.map +1 -1
- package/docs/Spreadsheet.md +324 -19
- package/package.json +1 -1
package/docs/Spreadsheet.md
CHANGED
|
@@ -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
|
-
###
|
|
175
|
+
### SpreadsheetContextMenuItem
|
|
170
176
|
|
|
171
177
|
```ts
|
|
172
178
|
interface SpreadsheetContextMenuItem {
|
|
173
179
|
label: string;
|
|
174
|
-
onClick
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
{
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|