@xcelsior/ui-spreadsheets 1.2.1 → 1.3.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/.omc/state/agent-replay-0cead415-b3bd-40fd-b199-47371946c4db.jsonl +25 -0
- package/.omc/state/idle-notif-cooldown.json +3 -0
- package/.omc/state/last-tool-error.json +7 -0
- package/.omc/state/mission-state.json +179 -0
- package/.omc/state/subagent-tracking.json +116 -0
- package/.turbo/turbo-build.log +28 -28
- package/.turbo/turbo-lint.log +140 -0
- package/dist/index.d.mts +94 -4
- package/dist/index.d.ts +94 -4
- package/dist/index.js +2133 -1155
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2023 -1047
- package/dist/index.mjs.map +1 -1
- package/dist/styles/globals.css +159 -16
- package/dist/styles/globals.css.map +1 -1
- package/package.json +1 -1
- package/plans/20260330-1230-spreadsheet-features/phase-01-types-and-duplicates-hook.md +73 -0
- package/plans/20260330-1230-spreadsheet-features/phase-02-filter-dropdown-portal.md +90 -0
- package/plans/20260330-1230-spreadsheet-features/phase-03-header-overflow-menu.md +101 -0
- package/plans/20260330-1230-spreadsheet-features/phase-04-integration.md +193 -0
- package/plans/20260330-1230-spreadsheet-features/plan.md +59 -0
- package/src/components/ColorPickerPopover.tsx +77 -32
- package/src/components/ColumnHeaderActions.tsx +241 -1
- package/src/components/RowIndexColumnHeader.tsx +13 -16
- package/src/components/SelectionSummaryBar.tsx +103 -0
- package/src/components/Spreadsheet.stories.tsx +396 -0
- package/src/components/Spreadsheet.tsx +233 -187
- package/src/components/SpreadsheetCell.tsx +280 -42
- package/src/components/SpreadsheetFilterDropdown.tsx +178 -13
- package/src/components/SpreadsheetHeader.tsx +79 -24
- package/src/components/SpreadsheetSettingsModal.tsx +4 -0
- package/src/hooks/useSpreadsheetColumnResize.ts +143 -0
- package/src/hooks/useSpreadsheetDuplicates.ts +149 -0
- package/src/hooks/useSpreadsheetFiltering.ts +18 -1
- package/src/hooks/useSpreadsheetHighlighting.ts +23 -3
- package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +16 -0
- package/src/hooks/useSpreadsheetPinning.ts +148 -134
- package/src/hooks/useSpreadsheetSelection.ts +10 -22
- package/src/hooks/useSpreadsheetSummary.ts +68 -0
- package/src/index.ts +4 -1
- package/src/styles/globals.css +51 -0
- package/src/types.ts +50 -2
- package/storybook-static/assets/Color-YHDXOIA2-CtQurLnT.js +1 -0
- package/storybook-static/assets/DocsRenderer-CFRXHY34-oxrW8Hvo.js +575 -0
- package/storybook-static/assets/Spreadsheet.stories-DvhhzuK4.js +1357 -0
- package/storybook-static/assets/chunk-XP5HYGXS-BpfKkqn7.js +1 -0
- package/storybook-static/assets/entry-preview-CkBGHCAN.js +2 -0
- package/storybook-static/assets/entry-preview-docs-ugJb6pa8.js +46 -0
- package/storybook-static/assets/iframe-CPp2u3vg.js +211 -0
- package/storybook-static/assets/index-BB9bPxRC.js +24 -0
- package/storybook-static/assets/index-BQFlzFLk.js +9 -0
- package/storybook-static/assets/index-CtvPRVHf.js +9 -0
- package/storybook-static/assets/index-DgH-xKnr.js +11 -0
- package/storybook-static/assets/index-DrFu-skq.js +6 -0
- package/storybook-static/assets/index-DrdPSA1J.js +240 -0
- package/storybook-static/assets/index-DzFBShOR.js +20 -0
- package/storybook-static/assets/index-v-1boR4t.js +1 -0
- package/storybook-static/assets/preview-B8lJiyuQ.js +34 -0
- package/storybook-static/assets/preview-BBWR9nbA.js +1 -0
- package/storybook-static/assets/preview-BWzBA1C2.js +396 -0
- package/storybook-static/assets/preview-Bm0S-uxO.css +1 -0
- package/storybook-static/assets/preview-CvbIS5ZJ.js +1 -0
- package/storybook-static/assets/preview-DD_OYowb.js +1 -0
- package/storybook-static/assets/preview-DGUiP6tS.js +7 -0
- package/storybook-static/assets/preview-DHQbi4pV.js +1 -0
- package/storybook-static/assets/preview-DwI0w3cI.js +1 -0
- package/storybook-static/assets/preview-DyR7iiFG.js +1 -0
- package/storybook-static/assets/preview-zxZ6Be2V.js +2 -0
- package/storybook-static/assets/react-18-Pj8skaX9.js +1 -0
- package/storybook-static/assets/test-utils-quxJ1Z79.js +9 -0
- package/storybook-static/favicon.svg +1 -0
- package/storybook-static/iframe.html +666 -0
- package/storybook-static/index.html +177 -0
- package/storybook-static/index.json +1 -0
- package/storybook-static/nunito-sans-bold-italic.woff2 +0 -0
- package/storybook-static/nunito-sans-bold.woff2 +0 -0
- package/storybook-static/nunito-sans-italic.woff2 +0 -0
- package/storybook-static/nunito-sans-regular.woff2 +0 -0
- package/storybook-static/project.json +1 -0
- package/storybook-static/sb-addons/essentials-actions-3/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-backgrounds-5/manager-bundle.js +12 -0
- package/storybook-static/sb-addons/essentials-controls-2/manager-bundle.js +405 -0
- package/storybook-static/sb-addons/essentials-docs-4/manager-bundle.js +245 -0
- package/storybook-static/sb-addons/essentials-measure-8/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-outline-9/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-toolbars-7/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/essentials-viewport-6/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/interactions-10/manager-bundle.js +222 -0
- package/storybook-static/sb-addons/links-1/manager-bundle.js +3 -0
- package/storybook-static/sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js +3 -0
- package/storybook-static/sb-common-assets/favicon.svg +1 -0
- package/storybook-static/sb-common-assets/nunito-sans-bold-italic.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-bold.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-italic.woff2 +0 -0
- package/storybook-static/sb-common-assets/nunito-sans-regular.woff2 +0 -0
- package/storybook-static/sb-manager/globals-module-info.js +1052 -0
- package/storybook-static/sb-manager/globals-runtime.js +42127 -0
- package/storybook-static/sb-manager/globals.js +48 -0
- package/storybook-static/sb-manager/runtime.js +12048 -0
|
@@ -2,8 +2,8 @@ import { cn } from '../utils';
|
|
|
2
2
|
import { ColumnHeaderActions } from './ColumnHeaderActions';
|
|
3
3
|
import { ROW_INDEX_COLUMN_WIDTH } from '../hooks/useSpreadsheetPinning';
|
|
4
4
|
|
|
5
|
-
const cellPaddingCompact = 'px-1 py-
|
|
6
|
-
const cellPaddingNormal = 'px-2 py-
|
|
5
|
+
const cellPaddingCompact = 'px-1.5 py-1';
|
|
6
|
+
const cellPaddingNormal = 'px-2 py-2';
|
|
7
7
|
|
|
8
8
|
export interface RowIndexColumnHeaderProps {
|
|
9
9
|
/** Whether highlighting is enabled */
|
|
@@ -16,7 +16,7 @@ export interface RowIndexColumnHeaderProps {
|
|
|
16
16
|
onHighlightClick?: () => void;
|
|
17
17
|
/** Callback when pin button is clicked */
|
|
18
18
|
onPinClick?: () => void;
|
|
19
|
-
/** Whether this is the empty placeholder cell in the
|
|
19
|
+
/** Whether this is the empty placeholder cell in the group header row */
|
|
20
20
|
isSecondRow?: boolean;
|
|
21
21
|
/** Whether compact mode is enabled */
|
|
22
22
|
compactMode?: boolean;
|
|
@@ -27,6 +27,7 @@ export interface RowIndexColumnHeaderProps {
|
|
|
27
27
|
/**
|
|
28
28
|
* Row index column header (#) with highlight and pin actions.
|
|
29
29
|
* Uses the same ColumnHeaderActions component as regular columns for consistency.
|
|
30
|
+
* Vertical sticking is handled by the parent <thead>, this only handles horizontal sticking when pinned.
|
|
30
31
|
*/
|
|
31
32
|
export function RowIndexColumnHeader({
|
|
32
33
|
enableHighlighting = false,
|
|
@@ -40,23 +41,21 @@ export function RowIndexColumnHeader({
|
|
|
40
41
|
}: RowIndexColumnHeaderProps) {
|
|
41
42
|
const cellPadding = compactMode ? cellPaddingCompact : cellPaddingNormal;
|
|
42
43
|
|
|
43
|
-
//
|
|
44
|
+
// Group header row placeholder - always sticky left
|
|
44
45
|
if (isSecondRow) {
|
|
45
46
|
return (
|
|
46
47
|
<th
|
|
47
48
|
className={cn(
|
|
48
|
-
'border border-gray-200 text-center font-bold text-gray-700',
|
|
49
|
-
compactMode ? 'text-
|
|
49
|
+
'border border-gray-200 text-center font-bold text-gray-700 sticky',
|
|
50
|
+
compactMode ? 'text-xs' : 'text-sm',
|
|
50
51
|
cellPadding,
|
|
51
|
-
isPinned ? 'z-30' : 'z-20',
|
|
52
52
|
className
|
|
53
53
|
)}
|
|
54
54
|
style={{
|
|
55
55
|
minWidth: `${ROW_INDEX_COLUMN_WIDTH}px`,
|
|
56
56
|
width: `${ROW_INDEX_COLUMN_WIDTH}px`,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
left: isPinned ? 0 : undefined,
|
|
57
|
+
left: 0,
|
|
58
|
+
zIndex: 50,
|
|
60
59
|
backgroundColor: highlightColor || 'rgb(243 244 246)',
|
|
61
60
|
}}
|
|
62
61
|
/>
|
|
@@ -66,18 +65,16 @@ export function RowIndexColumnHeader({
|
|
|
66
65
|
return (
|
|
67
66
|
<th
|
|
68
67
|
className={cn(
|
|
69
|
-
'border border-gray-200 text-center font-bold text-gray-700 group',
|
|
70
|
-
compactMode ? 'text-
|
|
68
|
+
'border border-gray-200 text-center font-bold text-gray-700 group sticky',
|
|
69
|
+
compactMode ? 'text-xs' : 'text-sm',
|
|
71
70
|
cellPadding,
|
|
72
|
-
isPinned ? 'z-30' : 'z-20',
|
|
73
71
|
className
|
|
74
72
|
)}
|
|
75
73
|
style={{
|
|
76
74
|
minWidth: `${ROW_INDEX_COLUMN_WIDTH}px`,
|
|
77
75
|
width: `${ROW_INDEX_COLUMN_WIDTH}px`,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
left: isPinned ? 0 : undefined,
|
|
76
|
+
left: 0,
|
|
77
|
+
zIndex: 50,
|
|
81
78
|
backgroundColor: highlightColor || 'rgb(243 244 246)',
|
|
82
79
|
}}
|
|
83
80
|
>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { SelectionSummary } from '../hooks/useSpreadsheetSummary';
|
|
2
|
+
import type { CellPosition, SpreadsheetColumn } from '../types';
|
|
3
|
+
|
|
4
|
+
export interface SelectionSummaryBarProps<T> {
|
|
5
|
+
/** Computed summary for numeric selections */
|
|
6
|
+
summary: SelectionSummary | null;
|
|
7
|
+
/** Currently focused cell */
|
|
8
|
+
focusedCell: CellPosition | null;
|
|
9
|
+
/** Column definitions */
|
|
10
|
+
columns: SpreadsheetColumn<T>[];
|
|
11
|
+
/** Current page's data */
|
|
12
|
+
data: T[];
|
|
13
|
+
/** Function to get row ID */
|
|
14
|
+
getRowId: (row: T) => string | number;
|
|
15
|
+
/** Current page number */
|
|
16
|
+
currentPage: number;
|
|
17
|
+
/** Page size */
|
|
18
|
+
pageSize: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Combined status bar showing cell address (left) and selection summary (right).
|
|
23
|
+
* Similar to Excel's bottom status bar.
|
|
24
|
+
*/
|
|
25
|
+
export function SelectionSummaryBar<T extends Record<string, any>>({
|
|
26
|
+
summary,
|
|
27
|
+
focusedCell,
|
|
28
|
+
columns,
|
|
29
|
+
data,
|
|
30
|
+
getRowId,
|
|
31
|
+
currentPage,
|
|
32
|
+
pageSize,
|
|
33
|
+
}: SelectionSummaryBarProps<T>) {
|
|
34
|
+
// Compute address info
|
|
35
|
+
let addressDisplay: string | null = null;
|
|
36
|
+
let valueDisplay: string | null = null;
|
|
37
|
+
|
|
38
|
+
if (focusedCell) {
|
|
39
|
+
const column = columns.find((c) => c.id === focusedCell.columnId);
|
|
40
|
+
const rowIndex = data.findIndex((r) => getRowId(r) === focusedCell.rowId);
|
|
41
|
+
const row = rowIndex !== -1 ? data[rowIndex] : null;
|
|
42
|
+
|
|
43
|
+
if (column && row) {
|
|
44
|
+
const displayRowIndex = rowIndex + 1 + (currentPage - 1) * pageSize;
|
|
45
|
+
addressDisplay = `Row ${displayRowIndex} / ${column.label}`;
|
|
46
|
+
const value = column.getValue ? column.getValue(row) : row[focusedCell.columnId];
|
|
47
|
+
if (value !== null && value !== undefined && value !== '') {
|
|
48
|
+
valueDisplay = String(value);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Don't render if nothing to show
|
|
54
|
+
if (!addressDisplay && !summary) return null;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-50 border-t border-gray-200 text-xs text-gray-600">
|
|
58
|
+
{/* Left: Cell address + value */}
|
|
59
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
60
|
+
{addressDisplay && (
|
|
61
|
+
<>
|
|
62
|
+
<span className="font-medium text-gray-500 bg-white px-2 py-0.5 rounded border border-gray-200 shrink-0">
|
|
63
|
+
{addressDisplay}
|
|
64
|
+
</span>
|
|
65
|
+
{valueDisplay && (
|
|
66
|
+
<span className="text-gray-700 truncate" title={valueDisplay}>
|
|
67
|
+
{valueDisplay}
|
|
68
|
+
</span>
|
|
69
|
+
)}
|
|
70
|
+
</>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{/* Right: Selection summary */}
|
|
75
|
+
{summary && (
|
|
76
|
+
<div className="flex items-center gap-4 shrink-0">
|
|
77
|
+
<span>
|
|
78
|
+
<span className="text-gray-400 mr-1">Count:</span>
|
|
79
|
+
<span className="font-medium text-gray-700">{summary.numericCount}</span>
|
|
80
|
+
</span>
|
|
81
|
+
<span>
|
|
82
|
+
<span className="text-gray-400 mr-1">Sum:</span>
|
|
83
|
+
<span className="font-medium text-gray-700">{summary.sum.toLocaleString()}</span>
|
|
84
|
+
</span>
|
|
85
|
+
<span>
|
|
86
|
+
<span className="text-gray-400 mr-1">Avg:</span>
|
|
87
|
+
<span className="font-medium text-gray-700">{summary.avg.toLocaleString()}</span>
|
|
88
|
+
</span>
|
|
89
|
+
<span>
|
|
90
|
+
<span className="text-gray-400 mr-1">Min:</span>
|
|
91
|
+
<span className="font-medium text-gray-700">{summary.min.toLocaleString()}</span>
|
|
92
|
+
</span>
|
|
93
|
+
<span>
|
|
94
|
+
<span className="text-gray-400 mr-1">Max:</span>
|
|
95
|
+
<span className="font-medium text-gray-700">{summary.max.toLocaleString()}</span>
|
|
96
|
+
</span>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
SelectionSummaryBar.displayName = 'SelectionSummaryBar';
|
|
@@ -368,6 +368,148 @@ export const WithColumnGroups: Story = {
|
|
|
368
368
|
},
|
|
369
369
|
};
|
|
370
370
|
|
|
371
|
+
// With column groups and vertical scrolling to test sticky headers
|
|
372
|
+
export const WithColumnGroupsScrolling: Story = {
|
|
373
|
+
render: () => {
|
|
374
|
+
// Generate more data to ensure we need to scroll
|
|
375
|
+
const largeUserData = Array.from({ length: 200 }, (_, i) => ({
|
|
376
|
+
id: i + 1,
|
|
377
|
+
name: `User ${i + 1}`,
|
|
378
|
+
email: `user${i + 1}@example.com`,
|
|
379
|
+
department: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'][i % 5],
|
|
380
|
+
role: ['Admin', 'Manager', 'Developer', 'Designer', 'Analyst'][i % 5],
|
|
381
|
+
status: ['Active', 'Inactive', 'Pending'][i % 3],
|
|
382
|
+
salary: 50000 + Math.floor(Math.random() * 100000),
|
|
383
|
+
startDate: new Date(2020 + (i % 5), i % 12, (i % 28) + 1).toISOString().split('T')[0],
|
|
384
|
+
isActive: i % 3 !== 1,
|
|
385
|
+
// Additional work fields
|
|
386
|
+
manager: `Manager ${(i % 10) + 1}`,
|
|
387
|
+
team: ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon'][i % 5],
|
|
388
|
+
location: ['New York', 'San Francisco', 'London', 'Tokyo', 'Sydney'][i % 5],
|
|
389
|
+
level: ['Junior', 'Mid', 'Senior', 'Lead', 'Principal'][i % 5],
|
|
390
|
+
yearsExp: (i % 15) + 1,
|
|
391
|
+
}));
|
|
392
|
+
|
|
393
|
+
// Extended columns including additional work fields
|
|
394
|
+
const extendedColumns: SpreadsheetColumn<any>[] = [
|
|
395
|
+
{ id: 'id', label: 'ID', width: 60, sortable: true, align: 'center' },
|
|
396
|
+
{ id: 'name', label: 'Name', width: 150, sortable: true, filterable: true },
|
|
397
|
+
{ id: 'email', label: 'Email', width: 220, sortable: true, filterable: true },
|
|
398
|
+
{ id: 'department', label: 'Department', width: 120, sortable: true, filterable: true },
|
|
399
|
+
{ id: 'role', label: 'Role', width: 120, sortable: true, filterable: true },
|
|
400
|
+
{ id: 'status', label: 'Status', width: 100, sortable: true, filterable: true },
|
|
401
|
+
{ id: 'manager', label: 'Manager', width: 120, sortable: true, filterable: true },
|
|
402
|
+
{ id: 'team', label: 'Team', width: 100, sortable: true, filterable: true },
|
|
403
|
+
{ id: 'location', label: 'Location', width: 120, sortable: true, filterable: true },
|
|
404
|
+
{ id: 'level', label: 'Level', width: 100, sortable: true, filterable: true },
|
|
405
|
+
{
|
|
406
|
+
id: 'yearsExp',
|
|
407
|
+
label: 'Years Exp',
|
|
408
|
+
width: 90,
|
|
409
|
+
sortable: true,
|
|
410
|
+
type: 'number',
|
|
411
|
+
align: 'right',
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
id: 'salary',
|
|
415
|
+
label: 'Salary',
|
|
416
|
+
width: 120,
|
|
417
|
+
sortable: true,
|
|
418
|
+
type: 'number',
|
|
419
|
+
align: 'right',
|
|
420
|
+
render: (value) => `$${value.toLocaleString()}`,
|
|
421
|
+
},
|
|
422
|
+
{ id: 'startDate', label: 'Start Date', width: 120, sortable: true, type: 'date' },
|
|
423
|
+
{ id: 'isActive', label: 'Active', width: 80, type: 'boolean', align: 'center' },
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
// Column groups with long names - work group has many columns so when collapsed the label wraps
|
|
427
|
+
const longNameColumnGroups: SpreadsheetColumnGroup[] = [
|
|
428
|
+
{
|
|
429
|
+
id: 'personal',
|
|
430
|
+
label: 'Personal Information Details',
|
|
431
|
+
columns: ['name', 'email'],
|
|
432
|
+
collapsible: true,
|
|
433
|
+
headerColor: '#e0f2fe',
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
id: 'work',
|
|
437
|
+
label: 'Work & Employment Information & Organization Structure',
|
|
438
|
+
columns: [
|
|
439
|
+
'department',
|
|
440
|
+
'role',
|
|
441
|
+
'status',
|
|
442
|
+
'manager',
|
|
443
|
+
'team',
|
|
444
|
+
'location',
|
|
445
|
+
'level',
|
|
446
|
+
'yearsExp',
|
|
447
|
+
],
|
|
448
|
+
collapsible: true,
|
|
449
|
+
headerColor: '#fef3c7',
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
id: 'compensation',
|
|
453
|
+
label: 'Compensation & Benefits Package',
|
|
454
|
+
columns: ['salary', 'startDate'],
|
|
455
|
+
collapsible: true,
|
|
456
|
+
headerColor: '#d1fae5',
|
|
457
|
+
},
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
return (
|
|
461
|
+
<div className="p-4">
|
|
462
|
+
<div className="mb-4 p-4 bg-amber-50 rounded-lg border border-amber-200">
|
|
463
|
+
<h3 className="font-semibold text-amber-900 mb-2">
|
|
464
|
+
Column Groups with Sticky Header Test
|
|
465
|
+
</h3>
|
|
466
|
+
<p className="text-sm text-amber-700 mb-3">
|
|
467
|
+
This story tests the sticky header behavior when using column groups. Scroll
|
|
468
|
+
down to verify that:
|
|
469
|
+
</p>
|
|
470
|
+
<ul className="text-sm text-amber-700 space-y-1 ml-4 list-disc">
|
|
471
|
+
<li>
|
|
472
|
+
The column group headers (Personal Info, Work Info, Compensation) stay
|
|
473
|
+
fixed at the top
|
|
474
|
+
</li>
|
|
475
|
+
<li>
|
|
476
|
+
The column headers (Name, Email, etc.) stay fixed below the group
|
|
477
|
+
headers
|
|
478
|
+
</li>
|
|
479
|
+
<li>The row index column stays visible</li>
|
|
480
|
+
<li>Group collapse/expand still works while scrolled</li>
|
|
481
|
+
</ul>
|
|
482
|
+
<p className="text-sm text-red-600 mt-3 font-medium">
|
|
483
|
+
Bug to reproduce: Collapse the "Work & Employment Information & Organization
|
|
484
|
+
Structure" group - the long header text wraps to 2 lines, which causes
|
|
485
|
+
scrolling issues with the sticky header.
|
|
486
|
+
</p>
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
<div
|
|
490
|
+
style={{
|
|
491
|
+
height: '500px',
|
|
492
|
+
overflow: 'auto',
|
|
493
|
+
border: '1px solid #e5e7eb',
|
|
494
|
+
borderRadius: '8px',
|
|
495
|
+
}}
|
|
496
|
+
>
|
|
497
|
+
<Spreadsheet
|
|
498
|
+
data={largeUserData}
|
|
499
|
+
columns={extendedColumns}
|
|
500
|
+
columnGroups={longNameColumnGroups}
|
|
501
|
+
getRowId={(row) => row.id}
|
|
502
|
+
showToolbar={false}
|
|
503
|
+
showPagination={false}
|
|
504
|
+
enableRowSelection
|
|
505
|
+
enableCellEditing={false}
|
|
506
|
+
/>
|
|
507
|
+
</div>
|
|
508
|
+
</div>
|
|
509
|
+
);
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
|
|
371
513
|
// Products spreadsheet
|
|
372
514
|
export const ProductsSpreadsheet: Story = {
|
|
373
515
|
args: {
|
|
@@ -1973,6 +2115,260 @@ export const PinnedColumnsWithGroups: Story = {
|
|
|
1973
2115
|
};
|
|
1974
2116
|
|
|
1975
2117
|
// With Right-Pinned Columns
|
|
2118
|
+
/**
|
|
2119
|
+
* Comprehensive test story that simulates a real-world logistics spreadsheet.
|
|
2120
|
+
* 30+ columns across 5 groups, editable cells, 200 rows — enough to test:
|
|
2121
|
+
* - Horizontal scrolling with many columns
|
|
2122
|
+
* - Double-click to edit
|
|
2123
|
+
* - Drag-to-select
|
|
2124
|
+
* - Ctrl/Cmd+A select all
|
|
2125
|
+
* - Zebra striping
|
|
2126
|
+
* - Sticky row index
|
|
2127
|
+
* - Enter to submit filter
|
|
2128
|
+
* - Compact vs normal mode toggle
|
|
2129
|
+
*/
|
|
2130
|
+
interface DocketRecord {
|
|
2131
|
+
id: number;
|
|
2132
|
+
rego: string;
|
|
2133
|
+
driver: string;
|
|
2134
|
+
type: string;
|
|
2135
|
+
date: string;
|
|
2136
|
+
job: string;
|
|
2137
|
+
customer: string;
|
|
2138
|
+
custInv: string;
|
|
2139
|
+
from: string;
|
|
2140
|
+
tipSite: string;
|
|
2141
|
+
comment: string;
|
|
2142
|
+
docket: string;
|
|
2143
|
+
custDocket: string;
|
|
2144
|
+
uom: string;
|
|
2145
|
+
qty: number;
|
|
2146
|
+
rate: number;
|
|
2147
|
+
totalPre: number;
|
|
2148
|
+
totalPost: number;
|
|
2149
|
+
tipName: string;
|
|
2150
|
+
tipUom: string;
|
|
2151
|
+
tipQty: number;
|
|
2152
|
+
tipRate: number;
|
|
2153
|
+
tipPre: number;
|
|
2154
|
+
tipPost: number;
|
|
2155
|
+
tipInvoice: string;
|
|
2156
|
+
truckPre: number;
|
|
2157
|
+
truckPost: number;
|
|
2158
|
+
subRate: number;
|
|
2159
|
+
subPre: number;
|
|
2160
|
+
subPost: number;
|
|
2161
|
+
subInvoice: string;
|
|
2162
|
+
weekEnding: string;
|
|
2163
|
+
profit: number;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
const docketData: DocketRecord[] = Array.from({ length: 200 }, (_, i) => {
|
|
2167
|
+
const qty = Math.round((1 + Math.random() * 50) * 100) / 100;
|
|
2168
|
+
const rate = Math.round((10 + Math.random() * 90) * 100) / 100;
|
|
2169
|
+
const totalPre = Math.round(qty * rate * 100) / 100;
|
|
2170
|
+
const tipQty = Math.round((1 + Math.random() * 30) * 100) / 100;
|
|
2171
|
+
const tipRate = Math.round((5 + Math.random() * 40) * 100) / 100;
|
|
2172
|
+
const tipPre = Math.round(tipQty * tipRate * 100) / 100;
|
|
2173
|
+
const subRate = Math.round((20 + Math.random() * 60) * 100) / 100;
|
|
2174
|
+
const subPre = Math.round(qty * subRate * 100) / 100;
|
|
2175
|
+
const profit = Math.round((totalPre - tipPre - subPre) * 100) / 100;
|
|
2176
|
+
|
|
2177
|
+
return {
|
|
2178
|
+
id: i + 1,
|
|
2179
|
+
rego: ['SPR4RO', 'XO78SR', 'KO04KW', 'CL24SS', 'XP60LQ'][i % 5],
|
|
2180
|
+
driver: ['Donald Strathaam', 'James Urewis', 'Shaun Davis', 'Matthew Maddick', 'Steven Ayers'][i % 5],
|
|
2181
|
+
type: ['LNG', 'SUBBY'][i % 2],
|
|
2182
|
+
date: `2026-03-${String((i % 28) + 1).padStart(2, '0')}`,
|
|
2183
|
+
job: ['BORAL ALLOCATION', 'HOLCIM LYNWOOD - PEJAR', 'EARTHWORK FIVEDOCK', 'GAMUDA ECOR'][i % 4],
|
|
2184
|
+
customer: ['Boral Transport Ltd', 'Holcim (Australia) Pty Ltd', 'Earthworks Group', 'Gamuda Engineering'][i % 4],
|
|
2185
|
+
custInv: `INV-${String(i + 1).padStart(5, '0')}`,
|
|
2186
|
+
from: ['Unnamed Road, Marulan NSW 2579', 'Burrows Road South, Saint Peters NSW 2044', '25 Burrows Road South'][i % 3],
|
|
2187
|
+
tipSite: ['Goulburn Road, Pejar NSW 2583', 'Ada Avenue, Brookvale NSW 2100', '52 Bay Road, Taren Point'][i % 3],
|
|
2188
|
+
comment: i % 7 === 0 ? 'Check weight docket' : '',
|
|
2189
|
+
docket: `E0100${String(i + 24).padStart(2, '0')}`,
|
|
2190
|
+
custDocket: i % 3 === 0 ? `CD-${i + 100}` : '',
|
|
2191
|
+
uom: ['Tonne', 'Load', 'Hour'][i % 3],
|
|
2192
|
+
qty,
|
|
2193
|
+
rate,
|
|
2194
|
+
totalPre,
|
|
2195
|
+
totalPost: Math.round(totalPre * 1.1 * 100) / 100,
|
|
2196
|
+
tipName: ['Pejar Tip', 'Brookvale Tip', 'Taren Point Tip'][i % 3],
|
|
2197
|
+
tipUom: ['Tonne', 'Load'][i % 2],
|
|
2198
|
+
tipQty,
|
|
2199
|
+
tipRate,
|
|
2200
|
+
tipPre,
|
|
2201
|
+
tipPost: Math.round(tipPre * 1.1 * 100) / 100,
|
|
2202
|
+
tipInvoice: i % 4 === 0 ? `TIP-${i + 200}` : '',
|
|
2203
|
+
truckPre: Math.round((500 + Math.random() * 1500) * 100) / 100,
|
|
2204
|
+
truckPost: Math.round((550 + Math.random() * 1650) * 100) / 100,
|
|
2205
|
+
subRate,
|
|
2206
|
+
subPre,
|
|
2207
|
+
subPost: Math.round(subPre * 1.1 * 100) / 100,
|
|
2208
|
+
subInvoice: i % 5 === 0 ? `SUB-${i + 300}` : '',
|
|
2209
|
+
weekEnding: `2026-03-${String(Math.min(28, ((Math.floor(i / 7) + 1) * 7))).padStart(2, '0')}`,
|
|
2210
|
+
profit,
|
|
2211
|
+
};
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
const docketColumns: SpreadsheetColumn<DocketRecord>[] = [
|
|
2215
|
+
{ id: 'rego', label: 'Rego', width: 85, sortable: true, filterable: true, editable: true },
|
|
2216
|
+
{ id: 'driver', label: 'Driver', width: 160, sortable: true, filterable: true },
|
|
2217
|
+
{ id: 'type', label: 'Type', width: 70, sortable: true, filterable: true, editable: true, type: 'select', options: ['LNG', 'SUBBY'] },
|
|
2218
|
+
{ id: 'date', label: 'Date', width: 100, sortable: true, filterable: true, type: 'date' },
|
|
2219
|
+
{ id: 'job', label: 'Job', width: 180, sortable: true, filterable: true },
|
|
2220
|
+
{ id: 'customer', label: 'Customer', width: 180, sortable: true, filterable: true },
|
|
2221
|
+
{ id: 'custInv', label: 'Cust Inv#', width: 100, sortable: true, filterable: true, editable: true },
|
|
2222
|
+
{ id: 'from', label: 'From', width: 200, sortable: true, filterable: true, editable: true },
|
|
2223
|
+
{ id: 'tipSite', label: 'Tip Site', width: 200, sortable: true, filterable: true, editable: true },
|
|
2224
|
+
{ id: 'comment', label: 'Comment', width: 150, sortable: true, filterable: true, editable: true },
|
|
2225
|
+
{ id: 'docket', label: 'Docket', width: 90, sortable: true, filterable: true },
|
|
2226
|
+
{ id: 'custDocket', label: 'Cust Docket', width: 100, sortable: true, filterable: true, editable: true },
|
|
2227
|
+
{ id: 'uom', label: 'UoM', width: 70, editable: true, type: 'select', options: ['Tonne', 'Load', 'Hour'] },
|
|
2228
|
+
{ id: 'qty', label: 'Qty', width: 70, sortable: true, type: 'number', align: 'right', editable: true },
|
|
2229
|
+
{ id: 'rate', label: 'Rate', width: 70, sortable: true, type: 'number', align: 'right', editable: true },
|
|
2230
|
+
{ id: 'totalPre', label: 'Total Pre', width: 85, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
|
|
2231
|
+
{ id: 'totalPost', label: 'Total Post', width: 85, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
|
|
2232
|
+
{ id: 'tipName', label: 'Tip Name', width: 140, sortable: true, filterable: true, editable: true },
|
|
2233
|
+
{ id: 'tipUom', label: 'Tip UoM', width: 70, editable: true, type: 'select', options: ['Tonne', 'Load'] },
|
|
2234
|
+
{ id: 'tipQty', label: 'Tip Qty', width: 70, sortable: true, filterable: true, type: 'number', align: 'right', editable: true },
|
|
2235
|
+
{ id: 'tipRate', label: 'Tip Rate', width: 70, sortable: true, filterable: true, type: 'number', align: 'right', editable: true },
|
|
2236
|
+
{ id: 'tipPre', label: 'Tip Pre', width: 80, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
|
|
2237
|
+
{ id: 'tipPost', label: 'Tip Post', width: 80, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
|
|
2238
|
+
{ id: 'tipInvoice', label: 'Tip Invoice', width: 100, sortable: true, filterable: true, editable: true },
|
|
2239
|
+
{ id: 'truckPre', label: 'Truck Pre', width: 85, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
|
|
2240
|
+
{ id: 'truckPost', label: 'Truck Post', width: 85, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
|
|
2241
|
+
{ id: 'subRate', label: 'Sub Rate', width: 80, sortable: true, filterable: true, type: 'number', align: 'right', editable: true },
|
|
2242
|
+
{ id: 'subPre', label: 'Sub Pre', width: 80, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
|
|
2243
|
+
{ id: 'subPost', label: 'Sub Post', width: 80, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
|
|
2244
|
+
{ id: 'subInvoice', label: 'Sub Invoice', width: 100, sortable: true, filterable: true, editable: true },
|
|
2245
|
+
{ id: 'weekEnding', label: 'Week Ending', width: 100, sortable: true },
|
|
2246
|
+
{
|
|
2247
|
+
id: 'profit',
|
|
2248
|
+
label: 'Profit',
|
|
2249
|
+
width: 90,
|
|
2250
|
+
type: 'number',
|
|
2251
|
+
align: 'right',
|
|
2252
|
+
sortable: true,
|
|
2253
|
+
render: (v) => {
|
|
2254
|
+
const num = Number(v);
|
|
2255
|
+
const color = num < 0 ? 'text-red-600' : 'text-green-600';
|
|
2256
|
+
return <span className={`font-medium ${color}`}>${num.toFixed(2)}</span>;
|
|
2257
|
+
},
|
|
2258
|
+
},
|
|
2259
|
+
];
|
|
2260
|
+
|
|
2261
|
+
const docketColumnGroups: SpreadsheetColumnGroup[] = [
|
|
2262
|
+
{
|
|
2263
|
+
id: 'customer-billing',
|
|
2264
|
+
label: 'Customer & Billing',
|
|
2265
|
+
columns: ['rego', 'driver', 'type', 'date', 'job', 'customer', 'custInv', 'from', 'tipSite', 'comment', 'docket', 'custDocket', 'uom', 'qty', 'rate', 'totalPre', 'totalPost'],
|
|
2266
|
+
headerColor: '#93C5FD',
|
|
2267
|
+
collapsible: true,
|
|
2268
|
+
},
|
|
2269
|
+
{
|
|
2270
|
+
id: 'tip-site',
|
|
2271
|
+
label: 'Tip Site',
|
|
2272
|
+
columns: ['tipName', 'tipUom', 'tipQty', 'tipRate', 'tipPre', 'tipPost', 'tipInvoice'],
|
|
2273
|
+
headerColor: '#86EFAC',
|
|
2274
|
+
collapsible: true,
|
|
2275
|
+
},
|
|
2276
|
+
{
|
|
2277
|
+
id: 'truck-hire',
|
|
2278
|
+
label: 'Truck Hire',
|
|
2279
|
+
columns: ['truckPre', 'truckPost'],
|
|
2280
|
+
headerColor: '#FDBA74',
|
|
2281
|
+
collapsible: true,
|
|
2282
|
+
},
|
|
2283
|
+
{
|
|
2284
|
+
id: 'subcontractor',
|
|
2285
|
+
label: 'Subcontractor',
|
|
2286
|
+
columns: ['subRate', 'subPre', 'subPost', 'subInvoice'],
|
|
2287
|
+
headerColor: '#D8B4FE',
|
|
2288
|
+
collapsible: true,
|
|
2289
|
+
},
|
|
2290
|
+
{
|
|
2291
|
+
id: 'summary',
|
|
2292
|
+
label: 'Summary',
|
|
2293
|
+
columns: ['weekEnding', 'profit'],
|
|
2294
|
+
headerColor: '#D1D5DB',
|
|
2295
|
+
collapsible: true,
|
|
2296
|
+
},
|
|
2297
|
+
];
|
|
2298
|
+
|
|
2299
|
+
const COMPREHENSIVE_STORAGE_KEY = 'storybook-comprehensive-test-settings';
|
|
2300
|
+
|
|
2301
|
+
const getStoredComprehensiveSettings = () => {
|
|
2302
|
+
try {
|
|
2303
|
+
const stored = localStorage.getItem(COMPREHENSIVE_STORAGE_KEY);
|
|
2304
|
+
return stored ? JSON.parse(stored) : null;
|
|
2305
|
+
} catch { return null; }
|
|
2306
|
+
};
|
|
2307
|
+
|
|
2308
|
+
export const ComprehensiveTest: Story = {
|
|
2309
|
+
render: () => {
|
|
2310
|
+
const [data, setData] = useState(docketData);
|
|
2311
|
+
const storedSettings = getStoredComprehensiveSettings();
|
|
2312
|
+
|
|
2313
|
+
const handleCellsEdit = (edits: CellEdit[]) => {
|
|
2314
|
+
setData((prev) =>
|
|
2315
|
+
prev.map((row) => {
|
|
2316
|
+
const editsForRow = edits.filter((e) => e.rowId === row.id);
|
|
2317
|
+
if (editsForRow.length > 0) {
|
|
2318
|
+
const updates = editsForRow.reduce(
|
|
2319
|
+
(acc, edit) => ({ ...acc, [edit.columnId]: edit.value }),
|
|
2320
|
+
{}
|
|
2321
|
+
);
|
|
2322
|
+
return { ...row, ...updates };
|
|
2323
|
+
}
|
|
2324
|
+
return row;
|
|
2325
|
+
})
|
|
2326
|
+
);
|
|
2327
|
+
};
|
|
2328
|
+
|
|
2329
|
+
return (
|
|
2330
|
+
<div className="flex flex-col h-screen">
|
|
2331
|
+
<div className="p-3 bg-blue-50 border-b border-blue-200 shrink-0">
|
|
2332
|
+
<h3 className="font-semibold text-blue-900 mb-1">
|
|
2333
|
+
Comprehensive Test — Docket Management
|
|
2334
|
+
</h3>
|
|
2335
|
+
<p className="text-xs text-blue-700">
|
|
2336
|
+
32 columns, 5 groups, 200 rows. Double-click to edit | Shift+Click for range | Cmd+A select all | Click header to select column | Sort via icon | Enter to submit filter | Resize columns by dragging header edges
|
|
2337
|
+
</p>
|
|
2338
|
+
</div>
|
|
2339
|
+
|
|
2340
|
+
<div className="flex-1 min-h-0 p-2">
|
|
2341
|
+
<Spreadsheet
|
|
2342
|
+
data={data}
|
|
2343
|
+
columns={docketColumns}
|
|
2344
|
+
columnGroups={docketColumnGroups}
|
|
2345
|
+
getRowId={(row) => row.id}
|
|
2346
|
+
onCellsEdit={handleCellsEdit}
|
|
2347
|
+
showToolbar
|
|
2348
|
+
showPagination
|
|
2349
|
+
enableRowSelection
|
|
2350
|
+
enableCellEditing
|
|
2351
|
+
enableHighlighting
|
|
2352
|
+
enableComments
|
|
2353
|
+
enableUndoRedo
|
|
2354
|
+
settings={{
|
|
2355
|
+
defaultPinnedColumns: storedSettings?.defaultPinnedColumns ?? ['__row_index__'],
|
|
2356
|
+
defaultPageSize: storedSettings?.defaultPageSize ?? 100,
|
|
2357
|
+
defaultZoom: storedSettings?.defaultZoom ?? 100,
|
|
2358
|
+
autoSave: storedSettings?.autoSave ?? true,
|
|
2359
|
+
compactView: storedSettings?.compactView ?? false,
|
|
2360
|
+
columnWidths: storedSettings?.columnWidths,
|
|
2361
|
+
}}
|
|
2362
|
+
onSettingsChange={(settings) => {
|
|
2363
|
+
localStorage.setItem(COMPREHENSIVE_STORAGE_KEY, JSON.stringify(settings));
|
|
2364
|
+
}}
|
|
2365
|
+
/>
|
|
2366
|
+
</div>
|
|
2367
|
+
</div>
|
|
2368
|
+
);
|
|
2369
|
+
},
|
|
2370
|
+
};
|
|
2371
|
+
|
|
1976
2372
|
export const WithRightPinnedColumns: Story = {
|
|
1977
2373
|
render: () => {
|
|
1978
2374
|
const [data, setData] = useState(sampleUsers);
|