@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.
- package/.storybook/main.ts +27 -0
- package/.storybook/preview.tsx +28 -0
- package/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +9 -0
- package/biome.json +3 -0
- package/dist/index.d.mts +687 -0
- package/dist/index.d.ts +687 -0
- package/dist/index.js +3459 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3417 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +51 -0
- package/postcss.config.js +5 -0
- package/src/components/ColorPickerPopover.tsx +73 -0
- package/src/components/ColumnHeaderActions.tsx +139 -0
- package/src/components/CommentModals.tsx +137 -0
- package/src/components/KeyboardShortcutsModal.tsx +119 -0
- package/src/components/RowIndexColumnHeader.tsx +70 -0
- package/src/components/Spreadsheet.stories.tsx +1146 -0
- package/src/components/Spreadsheet.tsx +1005 -0
- package/src/components/SpreadsheetCell.tsx +341 -0
- package/src/components/SpreadsheetFilterDropdown.tsx +341 -0
- package/src/components/SpreadsheetHeader.tsx +111 -0
- package/src/components/SpreadsheetSettingsModal.tsx +555 -0
- package/src/components/SpreadsheetToolbar.tsx +346 -0
- package/src/hooks/index.ts +40 -0
- package/src/hooks/useSpreadsheetComments.ts +132 -0
- package/src/hooks/useSpreadsheetFiltering.ts +379 -0
- package/src/hooks/useSpreadsheetHighlighting.ts +201 -0
- package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +149 -0
- package/src/hooks/useSpreadsheetPinning.ts +203 -0
- package/src/hooks/useSpreadsheetUndoRedo.ts +167 -0
- package/src/index.ts +31 -0
- package/src/types.ts +612 -0
- package/src/utils.ts +16 -0
- package/tsconfig.json +30 -0
- 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
|
+
};
|