@xcelsior/ui-spreadsheets 1.0.1

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.
Files changed (37) hide show
  1. package/.storybook/main.ts +27 -0
  2. package/.storybook/preview.tsx +28 -0
  3. package/.turbo/turbo-build.log +22 -0
  4. package/CHANGELOG.md +9 -0
  5. package/biome.json +3 -0
  6. package/dist/index.d.mts +687 -0
  7. package/dist/index.d.ts +687 -0
  8. package/dist/index.js +3459 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/index.mjs +3417 -0
  11. package/dist/index.mjs.map +1 -0
  12. package/package.json +51 -0
  13. package/postcss.config.js +5 -0
  14. package/src/components/ColorPickerPopover.tsx +73 -0
  15. package/src/components/ColumnHeaderActions.tsx +139 -0
  16. package/src/components/CommentModals.tsx +137 -0
  17. package/src/components/KeyboardShortcutsModal.tsx +119 -0
  18. package/src/components/RowIndexColumnHeader.tsx +70 -0
  19. package/src/components/Spreadsheet.stories.tsx +1146 -0
  20. package/src/components/Spreadsheet.tsx +1005 -0
  21. package/src/components/SpreadsheetCell.tsx +341 -0
  22. package/src/components/SpreadsheetFilterDropdown.tsx +341 -0
  23. package/src/components/SpreadsheetHeader.tsx +111 -0
  24. package/src/components/SpreadsheetSettingsModal.tsx +555 -0
  25. package/src/components/SpreadsheetToolbar.tsx +346 -0
  26. package/src/hooks/index.ts +40 -0
  27. package/src/hooks/useSpreadsheetComments.ts +132 -0
  28. package/src/hooks/useSpreadsheetFiltering.ts +379 -0
  29. package/src/hooks/useSpreadsheetHighlighting.ts +201 -0
  30. package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +149 -0
  31. package/src/hooks/useSpreadsheetPinning.ts +203 -0
  32. package/src/hooks/useSpreadsheetUndoRedo.ts +167 -0
  33. package/src/index.ts +31 -0
  34. package/src/types.ts +612 -0
  35. package/src/utils.ts +16 -0
  36. package/tsconfig.json +30 -0
  37. package/tsup.config.ts +12 -0
@@ -0,0 +1,1146 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { useCallback, useEffect, useState } from 'react';
3
+ import { Spreadsheet } from './Spreadsheet';
4
+ import type {
5
+ SpreadsheetColumn,
6
+ SpreadsheetColumnGroup,
7
+ SpreadsheetSortConfig,
8
+ SpreadsheetColumnFilter,
9
+ } from '../types';
10
+ import { HiEye, HiPencil, HiTrash } from 'react-icons/hi';
11
+
12
+ // Sample data types
13
+ interface User {
14
+ id: number;
15
+ name: string;
16
+ email: string;
17
+ department: string;
18
+ role: string;
19
+ status: string;
20
+ salary: number;
21
+ startDate: string;
22
+ isActive: boolean;
23
+ }
24
+
25
+ interface Product {
26
+ id: number;
27
+ name: string;
28
+ category: string;
29
+ price: number;
30
+ stock: number;
31
+ sku: string;
32
+ supplier: string;
33
+ lastUpdated: string;
34
+ }
35
+
36
+ // Sample user data
37
+ const sampleUsers: User[] = Array.from({ length: 100 }, (_, i) => ({
38
+ id: i + 1,
39
+ name: `User ${i + 1}`,
40
+ email: `user${i + 1}@example.com`,
41
+ department: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'][i % 5],
42
+ role: ['Admin', 'Manager', 'Developer', 'Designer', 'Analyst'][i % 5],
43
+ status: ['Active', 'Inactive', 'Pending'][i % 3],
44
+ salary: 50000 + Math.floor(Math.random() * 100000),
45
+ startDate: new Date(2020 + (i % 5), i % 12, (i % 28) + 1).toISOString().split('T')[0],
46
+ isActive: i % 3 !== 1,
47
+ }));
48
+
49
+ // Sample product data
50
+ const sampleProducts: Product[] = Array.from({ length: 50 }, (_, i) => ({
51
+ id: i + 1,
52
+ name: `Product ${i + 1}`,
53
+ category: ['Electronics', 'Clothing', 'Food', 'Books', 'Home'][i % 5],
54
+ price: Math.round((10 + Math.random() * 990) * 100) / 100,
55
+ stock: Math.floor(Math.random() * 1000),
56
+ sku: `SKU-${String(i + 1).padStart(5, '0')}`,
57
+ supplier: ['Supplier A', 'Supplier B', 'Supplier C'][i % 3],
58
+ lastUpdated: new Date(2024, i % 12, (i % 28) + 1).toISOString().split('T')[0],
59
+ }));
60
+
61
+ // User columns
62
+ const userColumns: SpreadsheetColumn<User>[] = [
63
+ { id: 'id', label: 'ID', width: 60, sortable: true, align: 'center' },
64
+ { id: 'name', label: 'Name', width: 150, sortable: true, filterable: true, editable: true },
65
+ { id: 'email', label: 'Email', width: 220, sortable: true, filterable: true, editable: true },
66
+ {
67
+ id: 'department',
68
+ label: 'Department',
69
+ width: 120,
70
+ sortable: true,
71
+ filterable: true,
72
+ type: 'select',
73
+ options: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'],
74
+ },
75
+ {
76
+ id: 'role',
77
+ label: 'Role',
78
+ width: 120,
79
+ sortable: true,
80
+ filterable: true,
81
+ type: 'select',
82
+ options: ['Admin', 'Manager', 'Developer', 'Designer', 'Analyst'],
83
+ },
84
+ {
85
+ id: 'status',
86
+ label: 'Status',
87
+ width: 100,
88
+ sortable: true,
89
+ filterable: true,
90
+ type: 'select',
91
+ options: ['Active', 'Inactive', 'Pending'],
92
+ },
93
+ {
94
+ id: 'salary',
95
+ label: 'Salary',
96
+ width: 120,
97
+ sortable: true,
98
+ type: 'number',
99
+ align: 'right',
100
+ render: (value) => `$${value.toLocaleString()}`,
101
+ },
102
+ { id: 'startDate', label: 'Start Date', width: 120, sortable: true, type: 'date' },
103
+ { id: 'isActive', label: 'Active', width: 80, type: 'boolean', align: 'center' },
104
+ ];
105
+
106
+ // Product columns
107
+ const productColumns: SpreadsheetColumn<Product>[] = [
108
+ { id: 'id', label: 'ID', width: 60, sortable: true, align: 'center' },
109
+ { id: 'sku', label: 'SKU', width: 120, sortable: true, filterable: true },
110
+ {
111
+ id: 'name',
112
+ label: 'Product Name',
113
+ width: 180,
114
+ sortable: true,
115
+ filterable: true,
116
+ editable: true,
117
+ },
118
+ {
119
+ id: 'category',
120
+ label: 'Category',
121
+ width: 120,
122
+ sortable: true,
123
+ filterable: true,
124
+ type: 'select',
125
+ options: ['Electronics', 'Clothing', 'Food', 'Books', 'Home'],
126
+ },
127
+ {
128
+ id: 'price',
129
+ label: 'Price',
130
+ width: 100,
131
+ sortable: true,
132
+ type: 'number',
133
+ align: 'right',
134
+ render: (value) => `$${value.toFixed(2)}`,
135
+ },
136
+ { id: 'stock', label: 'Stock', width: 80, sortable: true, type: 'number', align: 'right' },
137
+ { id: 'supplier', label: 'Supplier', width: 120, sortable: true, filterable: true },
138
+ { id: 'lastUpdated', label: 'Last Updated', width: 120, sortable: true, type: 'date' },
139
+ ];
140
+
141
+ // Column groups example
142
+ const columnGroups: SpreadsheetColumnGroup[] = [
143
+ {
144
+ id: 'personal',
145
+ label: 'Personal Info',
146
+ columns: ['name', 'email'],
147
+ collapsible: true,
148
+ headerColor: '#e0f2fe',
149
+ },
150
+ {
151
+ id: 'work',
152
+ label: 'Work Info',
153
+ columns: ['department', 'role', 'status'],
154
+ collapsible: true,
155
+ headerColor: '#fef3c7',
156
+ },
157
+ {
158
+ id: 'compensation',
159
+ label: 'Compensation',
160
+ columns: ['salary', 'startDate'],
161
+ collapsible: true,
162
+ headerColor: '#d1fae5',
163
+ },
164
+ ];
165
+
166
+ const meta: Meta<typeof Spreadsheet> = {
167
+ title: 'Components/Spreadsheet',
168
+ component: Spreadsheet,
169
+ parameters: {
170
+ layout: 'fullscreen',
171
+ docs: {
172
+ description: {
173
+ component: `
174
+ A feature-rich spreadsheet component with Excel-like functionality.
175
+
176
+ ## Features
177
+ - **Sorting**: Click column headers to sort data
178
+ - **Filtering**: Filter by text, multi-select, or range
179
+ - **Cell Editing**: Double-click cells to edit (when enabled)
180
+ - **Row Selection**: Single, multi (Ctrl/Cmd+click), and range (Shift+click) selection
181
+ - **Column Pinning**: Pin columns to left or right
182
+ - **Column Grouping**: Group columns with collapsible headers
183
+ - **Pagination**: Navigate through large datasets
184
+ - **Zoom Controls**: Adjust the view zoom level
185
+ - **Undo/Redo**: Revert or replay changes
186
+ - **Cell Highlighting**: Highlight rows/columns with colors
187
+ - **Comments**: Add comments to rows
188
+ - **Keyboard Navigation**: Arrow keys, Tab, Enter, Escape
189
+ - **Row Actions**: Custom action buttons per row
190
+
191
+ ## Keyboard Shortcuts
192
+ - **Arrow Keys**: Navigate cells
193
+ - **Tab/Shift+Tab**: Move horizontally
194
+ - **Enter**: Start editing / Confirm edit
195
+ - **Escape**: Cancel editing
196
+ - **Ctrl/Cmd+Z**: Undo
197
+ - **Ctrl/Cmd+Shift+Z**: Redo
198
+ - **Ctrl/Cmd+C**: Copy cell value
199
+ - **Space**: Toggle row selection
200
+ - **Ctrl/Cmd+A**: Select all rows
201
+ `,
202
+ },
203
+ },
204
+ },
205
+ tags: ['autodocs'],
206
+ argTypes: {
207
+ data: { control: false },
208
+ columns: { control: false },
209
+ columnGroups: { control: false },
210
+ getRowId: { control: false },
211
+ onCellEdit: { action: 'onCellEdit' },
212
+ onSelectionChange: { action: 'onSelectionChange' },
213
+ onSortChange: { action: 'onSortChange' },
214
+ onFilterChange: { action: 'onFilterChange' },
215
+ onRowClick: { action: 'onRowClick' },
216
+ onRowDoubleClick: { action: 'onRowDoubleClick' },
217
+ onRowClone: { action: 'onRowClone' },
218
+ onAddRowComment: { action: 'onAddRowComment' },
219
+ onRowHighlight: { action: 'onRowHighlight' },
220
+ showToolbar: {
221
+ control: 'boolean',
222
+ description: 'Show/hide the toolbar with search, zoom, and settings',
223
+ },
224
+ showPagination: {
225
+ control: 'boolean',
226
+ description: 'Show/hide pagination controls',
227
+ },
228
+ showRowIndex: {
229
+ control: 'boolean',
230
+ description: 'Show/hide row index column (#)',
231
+ },
232
+ enableRowSelection: {
233
+ control: 'boolean',
234
+ description: 'Enable/disable row selection',
235
+ },
236
+ enableCellEditing: {
237
+ control: 'boolean',
238
+ description: 'Enable/disable inline cell editing',
239
+ },
240
+ enableComments: {
241
+ control: 'boolean',
242
+ description: 'Enable/disable row comments',
243
+ },
244
+ enableHighlighting: {
245
+ control: 'boolean',
246
+ description: 'Enable/disable row/column highlighting',
247
+ },
248
+ enableUndoRedo: {
249
+ control: 'boolean',
250
+ description: 'Enable/disable undo/redo functionality',
251
+ },
252
+ defaultPageSize: {
253
+ control: 'select',
254
+ options: [10, 25, 50, 100],
255
+ description: 'Default number of rows per page',
256
+ },
257
+ defaultZoom: {
258
+ control: { type: 'range', min: 50, max: 150, step: 10 },
259
+ description: 'Default zoom level (percentage)',
260
+ },
261
+ compactMode: {
262
+ control: 'boolean',
263
+ description: 'Use compact cell styling',
264
+ },
265
+ isLoading: {
266
+ control: 'boolean',
267
+ description: 'Show loading state',
268
+ },
269
+ emptyMessage: {
270
+ control: 'text',
271
+ description: 'Message to display when data is empty',
272
+ },
273
+ },
274
+ args: {
275
+ showToolbar: true,
276
+ showPagination: true,
277
+ showRowIndex: true,
278
+ enableRowSelection: true,
279
+ enableCellEditing: true,
280
+ enableComments: true,
281
+ enableHighlighting: true,
282
+ enableUndoRedo: true,
283
+ defaultPageSize: 25,
284
+ defaultZoom: 100,
285
+ compactMode: false,
286
+ isLoading: false,
287
+ emptyMessage: 'No data available',
288
+ },
289
+ };
290
+
291
+ export default meta;
292
+ type Story = StoryObj<typeof meta>;
293
+
294
+ // Default story with users (interactive with state management)
295
+ export const Default: Story = {
296
+ render: (args) => {
297
+ const [data, setData] = useState(sampleUsers);
298
+ const [, setSelectedRows] = useState<(string | number)[]>([]);
299
+
300
+ const handleCellEdit = (rowId: string | number, columnId: string, newValue: any) => {
301
+ console.log('Cell Edit:', { rowId, columnId, newValue });
302
+ setData((prev) =>
303
+ prev.map((row) => (row.id === rowId ? { ...row, [columnId]: newValue } : row))
304
+ );
305
+ };
306
+
307
+ const handleRowClone = (row: User, rowId: string | number) => {
308
+ const newId = Math.max(...data.map((r) => r.id)) + 1;
309
+ const clonedRow = { ...row, id: newId, name: `${row.name} (Copy)` };
310
+ const rowIndex = data.findIndex((r) => r.id === rowId);
311
+ const newData = [...data];
312
+ newData.splice(rowIndex + 1, 0, clonedRow);
313
+ setData(newData);
314
+ };
315
+
316
+ return (
317
+ <Spreadsheet
318
+ {...args}
319
+ data={data}
320
+ columns={userColumns}
321
+ getRowId={(row) => row.id}
322
+ onCellEdit={handleCellEdit}
323
+ onRowClone={handleRowClone}
324
+ onSelectionChange={setSelectedRows}
325
+ />
326
+ );
327
+ },
328
+ };
329
+
330
+ // Interactive example with state management
331
+ export const Interactive: Story = {
332
+ render: () => {
333
+ const [data, setData] = useState(sampleUsers);
334
+ const [selectedRows, setSelectedRows] = useState<(string | number)[]>([]);
335
+
336
+ const handleCellEdit = (rowId: string | number, columnId: string, newValue: any) => {
337
+ setData((prev) =>
338
+ prev.map((row) => (row.id === rowId ? { ...row, [columnId]: newValue } : row))
339
+ );
340
+ };
341
+
342
+ const handleRowClone = (row: User, rowId: string | number) => {
343
+ const newId = Math.max(...data.map((r) => r.id)) + 1;
344
+ const clonedRow = { ...row, id: newId, name: `${row.name} (Copy)` };
345
+ const rowIndex = data.findIndex((r) => r.id === rowId);
346
+ const newData = [...data];
347
+ newData.splice(rowIndex + 1, 0, clonedRow);
348
+ setData(newData);
349
+ };
350
+
351
+ return (
352
+ <div className="p-4">
353
+ <div className="mb-4 text-sm text-gray-600">
354
+ Selected rows: {selectedRows.length > 0 ? selectedRows.join(', ') : 'None'}
355
+ </div>
356
+ <Spreadsheet
357
+ data={data}
358
+ columns={userColumns}
359
+ getRowId={(row) => row.id}
360
+ onCellEdit={handleCellEdit}
361
+ onRowClone={handleRowClone}
362
+ onSelectionChange={setSelectedRows}
363
+ showToolbar
364
+ showPagination
365
+ enableRowSelection
366
+ enableCellEditing
367
+ />
368
+ </div>
369
+ );
370
+ },
371
+ };
372
+
373
+ // With column groups
374
+ export const WithColumnGroups: Story = {
375
+ args: {
376
+ data: sampleUsers,
377
+ columns: userColumns,
378
+ columnGroups: columnGroups,
379
+ getRowId: (row: User) => row.id,
380
+ },
381
+ };
382
+
383
+ // Products spreadsheet
384
+ export const ProductsSpreadsheet: Story = {
385
+ args: {
386
+ data: sampleProducts,
387
+ columns: productColumns,
388
+ getRowId: (row: Product) => row.id,
389
+ },
390
+ };
391
+
392
+ // Compact mode
393
+ export const CompactMode: Story = {
394
+ args: {
395
+ data: sampleUsers,
396
+ columns: userColumns,
397
+ getRowId: (row: User) => row.id,
398
+ compactMode: true,
399
+ defaultPageSize: 50,
400
+ },
401
+ };
402
+
403
+ // Read-only mode (no editing, no row actions)
404
+ export const ReadOnly: Story = {
405
+ args: {
406
+ data: sampleUsers,
407
+ columns: userColumns,
408
+ getRowId: (row: User) => row.id,
409
+ enableCellEditing: false,
410
+ enableComments: false,
411
+ enableHighlighting: false,
412
+ },
413
+ };
414
+
415
+ // Loading state
416
+ export const Loading: Story = {
417
+ args: {
418
+ data: [],
419
+ columns: userColumns,
420
+ getRowId: (row: User) => row.id,
421
+ isLoading: true,
422
+ },
423
+ };
424
+
425
+ // Empty state
426
+ export const Empty: Story = {
427
+ args: {
428
+ data: [],
429
+ columns: userColumns,
430
+ getRowId: (row: User) => row.id,
431
+ emptyMessage: 'No users found. Try adjusting your filters.',
432
+ },
433
+ };
434
+
435
+ // Minimal (no toolbar, no pagination)
436
+ export const Minimal: Story = {
437
+ args: {
438
+ data: sampleUsers.slice(0, 10),
439
+ columns: userColumns,
440
+ getRowId: (row: User) => row.id,
441
+ showToolbar: false,
442
+ showPagination: false,
443
+ showRowIndex: false,
444
+ },
445
+ };
446
+
447
+ // With row actions
448
+ export const WithRowActions: Story = {
449
+ render: () => {
450
+ const handleView = (row: User) => {
451
+ alert(`Viewing user: ${row.name}`);
452
+ };
453
+
454
+ const handleEdit = (row: User) => {
455
+ alert(`Editing user: ${row.name}`);
456
+ };
457
+
458
+ const handleDelete = (row: User) => {
459
+ if (confirm(`Delete user ${row.name}?`)) {
460
+ alert(`Deleted: ${row.name}`);
461
+ }
462
+ };
463
+
464
+ return (
465
+ <Spreadsheet
466
+ data={sampleUsers}
467
+ columns={userColumns}
468
+ getRowId={(row) => row.id}
469
+ rowActions={[
470
+ {
471
+ id: 'view',
472
+ icon: <HiEye className="w-4 h-4" />,
473
+ tooltip: 'View details',
474
+ onClick: handleView,
475
+ },
476
+ {
477
+ id: 'edit',
478
+ icon: <HiPencil className="w-4 h-4" />,
479
+ tooltip: 'Edit user',
480
+ onClick: handleEdit,
481
+ },
482
+ {
483
+ id: 'delete',
484
+ icon: <HiTrash className="w-4 h-4 text-red-500" />,
485
+ tooltip: 'Delete user',
486
+ onClick: handleDelete,
487
+ visible: (row) => row.status !== 'Active',
488
+ },
489
+ ]}
490
+ />
491
+ );
492
+ },
493
+ };
494
+
495
+ // With pre-existing highlights and comments
496
+ export const WithHighlightsAndComments: Story = {
497
+ args: {
498
+ data: sampleUsers,
499
+ columns: userColumns,
500
+ getRowId: (row: User) => row.id,
501
+ rowHighlights: [
502
+ { rowId: 1, color: '#fef3c7' },
503
+ { rowId: 3, color: '#d1fae5' },
504
+ { rowId: 5, color: '#fee2e2' },
505
+ ],
506
+ rowComments: [
507
+ {
508
+ id: '1',
509
+ rowId: 1,
510
+ text: 'VIP customer - handle with care',
511
+ author: 'Admin',
512
+ timestamp: new Date('2024-01-15'),
513
+ },
514
+ {
515
+ id: '2',
516
+ rowId: 3,
517
+ text: 'Needs follow-up next week',
518
+ author: 'Sales Team',
519
+ timestamp: new Date('2024-01-14'),
520
+ },
521
+ ],
522
+ },
523
+ };
524
+
525
+ // Large dataset (performance test)
526
+ export const LargeDataset: Story = {
527
+ args: {
528
+ data: Array.from({ length: 1000 }, (_, i) => ({
529
+ id: i + 1,
530
+ name: `User ${i + 1}`,
531
+ email: `user${i + 1}@example.com`,
532
+ department: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'][i % 5],
533
+ role: ['Admin', 'Manager', 'Developer', 'Designer', 'Analyst'][i % 5],
534
+ status: ['Active', 'Inactive', 'Pending'][i % 3],
535
+ salary: 50000 + Math.floor(Math.random() * 100000),
536
+ startDate: new Date(2020 + (i % 5), i % 12, (i % 28) + 1).toISOString().split('T')[0],
537
+ isActive: i % 3 !== 1,
538
+ })),
539
+ columns: userColumns,
540
+ getRowId: (row: User) => row.id,
541
+ defaultPageSize: 100,
542
+ },
543
+ };
544
+
545
+ // Custom zoom level
546
+ export const ZoomedOut: Story = {
547
+ args: {
548
+ data: sampleUsers,
549
+ columns: userColumns,
550
+ getRowId: (row: User) => row.id,
551
+ defaultZoom: 75,
552
+ },
553
+ };
554
+
555
+ // Custom zoom level (zoomed in)
556
+ export const ZoomedIn: Story = {
557
+ args: {
558
+ data: sampleUsers,
559
+ columns: userColumns,
560
+ getRowId: (row: User) => row.id,
561
+ defaultZoom: 125,
562
+ },
563
+ };
564
+
565
+ // Selection only mode (no editing)
566
+ export const SelectionOnly: Story = {
567
+ args: {
568
+ data: sampleUsers,
569
+ columns: userColumns,
570
+ getRowId: (row: User) => row.id,
571
+ enableRowSelection: true,
572
+ enableCellEditing: false,
573
+ },
574
+ render: (args) => {
575
+ const [selected, setSelected] = useState<(string | number)[]>([]);
576
+
577
+ return (
578
+ <div className="p-4">
579
+ <div className="mb-4 p-3 bg-blue-50 rounded-lg">
580
+ <strong>Selected Users:</strong>{' '}
581
+ {selected.length > 0
582
+ ? selected.map((id) => `User ${id}`).join(', ')
583
+ : 'None - Click rows to select'}
584
+ </div>
585
+ <Spreadsheet {...(args as any)} onSelectionChange={setSelected} />
586
+ </div>
587
+ );
588
+ },
589
+ };
590
+
591
+ // With custom cell rendering
592
+ export const CustomCellRendering: Story = {
593
+ args: {
594
+ data: sampleUsers,
595
+ columns: [
596
+ { id: 'id', label: 'ID', width: 60 },
597
+ { id: 'name', label: 'Name', width: 150, editable: true },
598
+ { id: 'email', label: 'Email', width: 220, editable: true },
599
+ {
600
+ id: 'status',
601
+ label: 'Status',
602
+ width: 120,
603
+ render: (value: string) => (
604
+ <span
605
+ className={`px-2 py-1 rounded-full text-xs font-medium ${
606
+ value === 'Active'
607
+ ? 'bg-green-100 text-green-800'
608
+ : value === 'Inactive'
609
+ ? 'bg-red-100 text-red-800'
610
+ : 'bg-yellow-100 text-yellow-800'
611
+ }`}
612
+ >
613
+ {value}
614
+ </span>
615
+ ),
616
+ },
617
+ {
618
+ id: 'salary',
619
+ label: 'Salary',
620
+ width: 150,
621
+ align: 'right' as const,
622
+ render: (value: number) => (
623
+ <div className="flex items-center justify-end gap-2">
624
+ <div
625
+ className="h-2 bg-blue-500 rounded"
626
+ style={{
627
+ width: `${Math.min((value / 150000) * 100, 100)}%`,
628
+ maxWidth: '80px',
629
+ }}
630
+ />
631
+ <span>${value.toLocaleString()}</span>
632
+ </div>
633
+ ),
634
+ },
635
+ {
636
+ id: 'isActive',
637
+ label: 'Active',
638
+ width: 80,
639
+ align: 'center' as const,
640
+ render: (value: boolean) => (value ? '✅' : '❌'),
641
+ },
642
+ ],
643
+ getRowId: (row: User) => row.id,
644
+ },
645
+ };
646
+
647
+ // Column Filtering Demo - showcasing all filter types
648
+ export const ColumnFiltering: Story = {
649
+ render: () => {
650
+ const [data] = useState(sampleUsers);
651
+ const [activeFilters, setActiveFilters] = useState<Record<string, any>>({});
652
+
653
+ // Columns with all filter types enabled
654
+ const filterableColumns: SpreadsheetColumn<User>[] = [
655
+ { id: 'id', label: 'ID', width: 60, sortable: true, align: 'center' },
656
+ {
657
+ id: 'name',
658
+ label: 'Name',
659
+ width: 150,
660
+ sortable: true,
661
+ filterable: true,
662
+ type: 'text',
663
+ },
664
+ {
665
+ id: 'email',
666
+ label: 'Email',
667
+ width: 220,
668
+ sortable: true,
669
+ filterable: true,
670
+ type: 'text',
671
+ },
672
+ {
673
+ id: 'department',
674
+ label: 'Department',
675
+ width: 130,
676
+ sortable: true,
677
+ filterable: true,
678
+ type: 'text',
679
+ },
680
+ {
681
+ id: 'salary',
682
+ label: 'Salary',
683
+ width: 120,
684
+ sortable: true,
685
+ filterable: true,
686
+ type: 'number',
687
+ align: 'right',
688
+ render: (value) => `$${value.toLocaleString()}`,
689
+ },
690
+ {
691
+ id: 'startDate',
692
+ label: 'Start Date',
693
+ width: 130,
694
+ sortable: true,
695
+ filterable: true,
696
+ type: 'date',
697
+ },
698
+ {
699
+ id: 'status',
700
+ label: 'Status',
701
+ width: 100,
702
+ sortable: true,
703
+ filterable: true,
704
+ type: 'text',
705
+ },
706
+ ];
707
+
708
+ const activeFilterCount = Object.keys(activeFilters).length;
709
+
710
+ return (
711
+ <div className="p-4">
712
+ <div className="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
713
+ <h3 className="font-semibold text-blue-900 mb-2">Column Filtering Demo</h3>
714
+ <p className="text-sm text-blue-700 mb-3">
715
+ Click the filter icon (funnel) in any column header to open the filter
716
+ dropdown. Different column types show different filter options:
717
+ </p>
718
+ <ul className="text-sm text-blue-700 space-y-1 ml-4 list-disc">
719
+ <li>
720
+ <strong>Text columns</strong> (Name, Email, Department, Status):
721
+ Contains, Starts with, Equals, etc.
722
+ </li>
723
+ <li>
724
+ <strong>Number columns</strong> (Salary): Greater than, Less than,
725
+ Between, Equals, etc.
726
+ </li>
727
+ <li>
728
+ <strong>Date columns</strong> (Start Date): Before, After, Between,
729
+ Today, This week, etc.
730
+ </li>
731
+ </ul>
732
+ {activeFilterCount > 0 && (
733
+ <div className="mt-3 pt-3 border-t border-blue-200">
734
+ <span className="text-sm font-medium text-blue-800">
735
+ Active filters: {activeFilterCount}
736
+ </span>
737
+ <pre className="mt-2 text-xs bg-white p-2 rounded border overflow-auto max-h-24">
738
+ {JSON.stringify(activeFilters, null, 2)}
739
+ </pre>
740
+ </div>
741
+ )}
742
+ </div>
743
+ <Spreadsheet
744
+ data={data}
745
+ columns={filterableColumns}
746
+ getRowId={(row) => row.id}
747
+ onFilterChange={setActiveFilters}
748
+ showToolbar
749
+ showPagination
750
+ enableRowSelection={false}
751
+ enableCellEditing={false}
752
+ defaultPageSize={25}
753
+ />
754
+ </div>
755
+ );
756
+ },
757
+ };
758
+
759
+ // ==================== SERVER-SIDE MODE STORIES ====================
760
+
761
+ // Large dataset for server-side simulation (1000 records)
762
+ const allServerData: User[] = Array.from({ length: 1000 }, (_, i) => ({
763
+ id: i + 1,
764
+ name: `User ${i + 1}`,
765
+ email: `user${i + 1}@example.com`,
766
+ department: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'][i % 5],
767
+ role: ['Admin', 'Manager', 'Developer', 'Designer', 'Analyst'][i % 5],
768
+ status: ['Active', 'Inactive', 'Pending'][i % 3],
769
+ salary: 50000 + Math.floor(Math.random() * 100000),
770
+ startDate: new Date(2020 + (i % 5), i % 12, (i % 28) + 1).toISOString().split('T')[0],
771
+ isActive: i % 3 !== 1,
772
+ }));
773
+
774
+ /**
775
+ * Simulates a server-side API call with filtering, sorting, and pagination.
776
+ * In a real application, this would be an API call to your backend.
777
+ */
778
+ function simulateServerFetch(params: {
779
+ page: number;
780
+ pageSize: number;
781
+ sortConfig: SpreadsheetSortConfig | null;
782
+ filters: Record<string, SpreadsheetColumnFilter>;
783
+ }): Promise<{ data: User[]; totalItems: number }> {
784
+ return new Promise((resolve) => {
785
+ // Simulate network delay
786
+ setTimeout(() => {
787
+ let result = [...allServerData];
788
+
789
+ // Apply filters (simulating server-side filtering)
790
+ for (const [columnId, filter] of Object.entries(params.filters)) {
791
+ if (!filter) continue;
792
+
793
+ if (filter.textCondition) {
794
+ const { operator, value } = filter.textCondition;
795
+ result = result.filter((row) => {
796
+ const cellValue = String((row as any)[columnId] ?? '').toLowerCase();
797
+ const filterValue = (value ?? '').toLowerCase();
798
+
799
+ switch (operator) {
800
+ case 'contains':
801
+ return cellValue.includes(filterValue);
802
+ case 'notContains':
803
+ return !cellValue.includes(filterValue);
804
+ case 'equals':
805
+ return cellValue === filterValue;
806
+ case 'notEquals':
807
+ return cellValue !== filterValue;
808
+ case 'startsWith':
809
+ return cellValue.startsWith(filterValue);
810
+ case 'endsWith':
811
+ return cellValue.endsWith(filterValue);
812
+ case 'isEmpty':
813
+ return !cellValue;
814
+ case 'isNotEmpty':
815
+ return !!cellValue;
816
+ default:
817
+ return true;
818
+ }
819
+ });
820
+ }
821
+
822
+ if (filter.numberCondition) {
823
+ const { operator, value, valueTo } = filter.numberCondition;
824
+ result = result.filter((row) => {
825
+ const cellValue = (row as any)[columnId];
826
+ if (operator === 'isEmpty') return cellValue == null;
827
+ if (operator === 'isNotEmpty') return cellValue != null;
828
+
829
+ const numValue = Number(cellValue);
830
+ if (Number.isNaN(numValue)) return false;
831
+
832
+ switch (operator) {
833
+ case 'equals':
834
+ return value !== undefined && numValue === value;
835
+ case 'notEquals':
836
+ return value !== undefined && numValue !== value;
837
+ case 'greaterThan':
838
+ return value !== undefined && numValue > value;
839
+ case 'greaterThanOrEqual':
840
+ return value !== undefined && numValue >= value;
841
+ case 'lessThan':
842
+ return value !== undefined && numValue < value;
843
+ case 'lessThanOrEqual':
844
+ return value !== undefined && numValue <= value;
845
+ case 'between':
846
+ return (
847
+ value !== undefined &&
848
+ valueTo !== undefined &&
849
+ numValue >= value &&
850
+ numValue <= valueTo
851
+ );
852
+ default:
853
+ return true;
854
+ }
855
+ });
856
+ }
857
+ }
858
+
859
+ // Apply sorting (simulating server-side sorting)
860
+ if (params.sortConfig) {
861
+ const { columnId, direction } = params.sortConfig;
862
+ result.sort((a, b) => {
863
+ const aValue = (a as any)[columnId];
864
+ const bValue = (b as any)[columnId];
865
+
866
+ if (aValue === null || aValue === undefined) return 1;
867
+ if (bValue === null || bValue === undefined) return -1;
868
+
869
+ if (typeof aValue === 'string' && typeof bValue === 'string') {
870
+ return direction === 'asc'
871
+ ? aValue.localeCompare(bValue)
872
+ : bValue.localeCompare(aValue);
873
+ }
874
+
875
+ return direction === 'asc'
876
+ ? aValue < bValue
877
+ ? -1
878
+ : aValue > bValue
879
+ ? 1
880
+ : 0
881
+ : aValue > bValue
882
+ ? -1
883
+ : aValue < bValue
884
+ ? 1
885
+ : 0;
886
+ });
887
+ }
888
+
889
+ const totalItems = result.length;
890
+
891
+ // Apply pagination (simulating server-side pagination)
892
+ const startIndex = (params.page - 1) * params.pageSize;
893
+ const paginatedData = result.slice(startIndex, startIndex + params.pageSize);
894
+
895
+ resolve({ data: paginatedData, totalItems });
896
+ }, 300); // 300ms simulated delay
897
+ });
898
+ }
899
+
900
+ // Server-side mode with full control
901
+ export const ServerSideMode: Story = {
902
+ render: () => {
903
+ // Server-side state
904
+ const [data, setData] = useState<User[]>([]);
905
+ const [totalItems, setTotalItems] = useState(0);
906
+ const [isLoading, setIsLoading] = useState(true);
907
+
908
+ // Controlled state for pagination, sorting, and filtering
909
+ const [currentPage, setCurrentPage] = useState(1);
910
+ const [pageSize, setPageSize] = useState(25);
911
+ const [sortConfig, setSortConfig] = useState<SpreadsheetSortConfig | null>(null);
912
+ const [filters, setFilters] = useState<Record<string, SpreadsheetColumnFilter>>({});
913
+
914
+ // Fetch data from "server"
915
+ const fetchData = useCallback(async () => {
916
+ setIsLoading(true);
917
+ const result = await simulateServerFetch({
918
+ page: currentPage,
919
+ pageSize,
920
+ sortConfig,
921
+ filters,
922
+ });
923
+ setData(result.data);
924
+ setTotalItems(result.totalItems);
925
+ setIsLoading(false);
926
+ }, [currentPage, pageSize, sortConfig, filters]);
927
+
928
+ // Fetch data whenever params change
929
+ useEffect(() => {
930
+ fetchData();
931
+ }, [fetchData]);
932
+
933
+ // Handle page change
934
+ const handlePageChange = (page: number, size: number) => {
935
+ if (size !== pageSize) {
936
+ setPageSize(size);
937
+ setCurrentPage(1); // Reset to page 1 when page size changes
938
+ } else {
939
+ setCurrentPage(page);
940
+ }
941
+ };
942
+
943
+ // Handle sort change
944
+ const handleSortChange = (newSortConfig: SpreadsheetSortConfig | null) => {
945
+ setSortConfig(newSortConfig);
946
+ setCurrentPage(1); // Reset to page 1 when sorting changes
947
+ };
948
+
949
+ // Handle filter change
950
+ const handleFilterChange = (newFilters: Record<string, SpreadsheetColumnFilter>) => {
951
+ setFilters(newFilters);
952
+ setCurrentPage(1); // Reset to page 1 when filters change
953
+ };
954
+
955
+ const filterableColumns: SpreadsheetColumn<User>[] = [
956
+ { id: 'id', label: 'ID', width: 60, sortable: true, align: 'center' },
957
+ {
958
+ id: 'name',
959
+ label: 'Name',
960
+ width: 150,
961
+ sortable: true,
962
+ filterable: true,
963
+ type: 'text',
964
+ },
965
+ {
966
+ id: 'email',
967
+ label: 'Email',
968
+ width: 220,
969
+ sortable: true,
970
+ filterable: true,
971
+ type: 'text',
972
+ },
973
+ {
974
+ id: 'department',
975
+ label: 'Department',
976
+ width: 130,
977
+ sortable: true,
978
+ filterable: true,
979
+ type: 'text',
980
+ },
981
+ {
982
+ id: 'salary',
983
+ label: 'Salary',
984
+ width: 120,
985
+ sortable: true,
986
+ filterable: true,
987
+ type: 'number',
988
+ align: 'right',
989
+ render: (value) => `$${value.toLocaleString()}`,
990
+ },
991
+ {
992
+ id: 'startDate',
993
+ label: 'Start Date',
994
+ width: 130,
995
+ sortable: true,
996
+ filterable: true,
997
+ type: 'date',
998
+ },
999
+ {
1000
+ id: 'status',
1001
+ label: 'Status',
1002
+ width: 100,
1003
+ sortable: true,
1004
+ filterable: true,
1005
+ type: 'text',
1006
+ },
1007
+ ];
1008
+
1009
+ return (
1010
+ <div className="p-4">
1011
+ <div className="mb-4 p-4 bg-purple-50 rounded-lg border border-purple-200">
1012
+ <h3 className="font-semibold text-purple-900 mb-2">Server-Side Mode Demo</h3>
1013
+ <p className="text-sm text-purple-700 mb-3">
1014
+ This example demonstrates server-side filtering, sorting, and pagination.
1015
+ The spreadsheet does NOT process data client-side - instead, it sends
1016
+ requests to the "server" (simulated) whenever you:
1017
+ </p>
1018
+ <ul className="text-sm text-purple-700 space-y-1 ml-4 list-disc">
1019
+ <li>Change pages or page size</li>
1020
+ <li>Sort by clicking column headers</li>
1021
+ <li>Apply filters using the filter dropdowns</li>
1022
+ </ul>
1023
+ <div className="mt-3 pt-3 border-t border-purple-200 grid grid-cols-2 gap-4 text-xs">
1024
+ <div>
1025
+ <span className="font-medium text-purple-800">Current State:</span>
1026
+ <pre className="mt-1 bg-white p-2 rounded border overflow-auto max-h-32">
1027
+ {JSON.stringify(
1028
+ {
1029
+ currentPage,
1030
+ pageSize,
1031
+ sortConfig,
1032
+ filterCount: Object.keys(filters).length,
1033
+ },
1034
+ null,
1035
+ 2
1036
+ )}
1037
+ </pre>
1038
+ </div>
1039
+ <div>
1040
+ <span className="font-medium text-purple-800">Server Response:</span>
1041
+ <pre className="mt-1 bg-white p-2 rounded border overflow-auto max-h-32">
1042
+ {JSON.stringify(
1043
+ { totalItems, loadedRows: data.length, isLoading },
1044
+ null,
1045
+ 2
1046
+ )}
1047
+ </pre>
1048
+ </div>
1049
+ </div>
1050
+ </div>
1051
+ <Spreadsheet
1052
+ data={data}
1053
+ columns={filterableColumns}
1054
+ getRowId={(row) => row.id}
1055
+ // Enable server-side mode
1056
+ serverSide
1057
+ totalItems={totalItems}
1058
+ // Controlled pagination
1059
+ currentPage={currentPage}
1060
+ pageSize={pageSize}
1061
+ onPageChange={handlePageChange}
1062
+ // Controlled sorting
1063
+ sortConfig={sortConfig}
1064
+ onSortChange={handleSortChange}
1065
+ // Controlled filtering
1066
+ filters={filters}
1067
+ onFilterChange={handleFilterChange}
1068
+ // Other props
1069
+ isLoading={isLoading}
1070
+ showToolbar
1071
+ showPagination
1072
+ enableRowSelection={false}
1073
+ enableCellEditing={false}
1074
+ pageSizeOptions={[10, 25, 50, 100]}
1075
+ />
1076
+ </div>
1077
+ );
1078
+ },
1079
+ };
1080
+
1081
+ // Server-side pagination only (sorting and filtering still client-side)
1082
+ export const ServerSidePaginationOnly: Story = {
1083
+ render: () => {
1084
+ const [data, setData] = useState<User[]>([]);
1085
+ const [totalItems] = useState(allServerData.length);
1086
+ const [isLoading, setIsLoading] = useState(true);
1087
+ const [currentPage, setCurrentPage] = useState(1);
1088
+ const [pageSize, setPageSize] = useState(25);
1089
+
1090
+ // Simulate fetching just the current page
1091
+ useEffect(() => {
1092
+ setIsLoading(true);
1093
+ const timer = setTimeout(() => {
1094
+ const startIndex = (currentPage - 1) * pageSize;
1095
+ setData(allServerData.slice(startIndex, startIndex + pageSize));
1096
+ setIsLoading(false);
1097
+ }, 200);
1098
+ return () => clearTimeout(timer);
1099
+ }, [currentPage, pageSize]);
1100
+
1101
+ const handlePageChange = (page: number, size: number) => {
1102
+ if (size !== pageSize) {
1103
+ setPageSize(size);
1104
+ setCurrentPage(1);
1105
+ } else {
1106
+ setCurrentPage(page);
1107
+ }
1108
+ };
1109
+
1110
+ return (
1111
+ <div className="p-4">
1112
+ <div className="mb-4 p-4 bg-green-50 rounded-lg border border-green-200">
1113
+ <h3 className="font-semibold text-green-900 mb-2">
1114
+ Server-Side Pagination Only
1115
+ </h3>
1116
+ <p className="text-sm text-green-700">
1117
+ This example shows server-side pagination while keeping client-side sorting
1118
+ and filtering. Useful when you have a large dataset but want quick local
1119
+ filtering on the current page.
1120
+ </p>
1121
+ <div className="mt-2 text-xs text-green-600">
1122
+ Page {currentPage} of {Math.ceil(totalItems / pageSize)} | Showing{' '}
1123
+ {data.length} of {totalItems} total items
1124
+ </div>
1125
+ </div>
1126
+ <Spreadsheet
1127
+ data={data}
1128
+ columns={userColumns}
1129
+ getRowId={(row) => row.id}
1130
+ // Server-side pagination
1131
+ serverSide
1132
+ totalItems={totalItems}
1133
+ currentPage={currentPage}
1134
+ pageSize={pageSize}
1135
+ onPageChange={handlePageChange}
1136
+ // Client-side sorting and filtering (no controlled state)
1137
+ isLoading={isLoading}
1138
+ showToolbar
1139
+ showPagination
1140
+ enableRowSelection
1141
+ enableCellEditing={false}
1142
+ />
1143
+ </div>
1144
+ );
1145
+ },
1146
+ };