@papernote/ui 1.10.25 → 1.10.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papernote/ui",
3
- "version": "1.10.25",
3
+ "version": "1.10.26",
4
4
  "type": "module",
5
5
  "description": "A modern React component library with a paper notebook aesthetic - minimal, professional, and expressive",
6
6
  "main": "dist/index.js",
@@ -159,6 +159,12 @@ export interface DataGridProps {
159
159
  className?: string;
160
160
  /** Density */
161
161
  density?: 'compact' | 'normal' | 'comfortable';
162
+ /** Enable virtual scrolling for large datasets (only renders visible rows) */
163
+ virtualized?: boolean;
164
+ /** Row height in pixels when virtualized (default: 40) */
165
+ virtualRowHeight?: number;
166
+ /** Number of rows to render above/below visible area (default: 5) */
167
+ virtualOverscan?: number;
162
168
  }
163
169
 
164
170
  /**
@@ -287,11 +293,15 @@ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
287
293
  toolbarActions,
288
294
  className = '',
289
295
  density = 'normal',
296
+ virtualized = false,
297
+ virtualRowHeight = 40,
298
+ virtualOverscan = 5,
290
299
  },
291
300
  ref
292
301
  ) => {
293
302
  // State
294
303
  const [data, setData] = useState<DataGridCell[][]>(initialData);
304
+ const [scrollTop, setScrollTop] = useState(0);
295
305
  const [editingCell, setEditingCell] = useState<{ row: number; col: number } | null>(null);
296
306
  const [editValue, setEditValue] = useState<string>('');
297
307
  const [sortConfig, setSortConfig] = useState<SortConfig | null>(null);
@@ -358,25 +368,18 @@ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
358
368
  [groupColorMap]
359
369
  );
360
370
 
361
- // Check if a specific row is frozen
362
- const isRowFrozen = useCallback(
363
- (rowIndex: number) => {
364
- if (frozenRowsState === 'none') return false;
365
- if (frozenRowsState === 'first') return rowIndex === 0;
366
- if (frozenRowsState === 'selected') {
367
- return selectedCell ? rowIndex === selectedCell.row : false;
368
- }
369
- if (typeof frozenRowsState === 'number') return rowIndex < frozenRowsState;
370
- return false;
371
- },
372
- [frozenRowsState, selectedCell]
373
- );
374
-
375
371
  // Update data when initialData changes
376
372
  useEffect(() => {
377
373
  setData(initialData);
378
374
  }, [initialData]);
379
375
 
376
+ // Handle scroll for virtualization
377
+ const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
378
+ if (virtualized) {
379
+ setScrollTop(e.currentTarget.scrollTop);
380
+ }
381
+ }, [virtualized]);
382
+
380
383
  // Get computed data with formulas evaluated
381
384
  // Uses a cache to handle formula dependencies (formulas referencing other formulas)
382
385
  const computedData = useMemo(() => {
@@ -496,6 +499,36 @@ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
496
499
  return [...frozenData, ...filtered];
497
500
  }, [sortedData, filters, columns, frozenRows]);
498
501
 
502
+ // Calculate visible rows for virtualization
503
+ const visibleRowRange = useMemo(() => {
504
+ if (!virtualized) {
505
+ return { startIndex: 0, endIndex: filteredData.length, paddingTop: 0, paddingBottom: 0, frozenRowCount: 0 };
506
+ }
507
+
508
+ const containerHeight = typeof height === 'number' ? height : 400;
509
+ const headerHeight = 40; // Approximate header height
510
+ const availableHeight = containerHeight - headerHeight;
511
+
512
+ // Account for frozen rows
513
+ const frozenRowCount = frozenRows;
514
+ const scrollableData = filteredData.slice(frozenRowCount);
515
+
516
+ const visibleCount = Math.ceil(availableHeight / virtualRowHeight);
517
+ const startIndex = Math.max(0, Math.floor(scrollTop / virtualRowHeight) - virtualOverscan);
518
+ const endIndex = Math.min(scrollableData.length, startIndex + visibleCount + (virtualOverscan * 2));
519
+
520
+ const paddingTop = startIndex * virtualRowHeight;
521
+ const paddingBottom = Math.max(0, (scrollableData.length - endIndex) * virtualRowHeight);
522
+
523
+ return {
524
+ startIndex: startIndex + frozenRowCount,
525
+ endIndex: endIndex + frozenRowCount,
526
+ paddingTop,
527
+ paddingBottom,
528
+ frozenRowCount
529
+ };
530
+ }, [virtualized, filteredData.length, scrollTop, height, virtualRowHeight, virtualOverscan, frozenRows]);
531
+
499
532
  // Handle cell edit start
500
533
  const handleCellDoubleClick = useCallback(
501
534
  (rowIndex: number, colIndex: number, cellElement?: HTMLElement) => {
@@ -866,6 +899,7 @@ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
866
899
  className="relative overflow-auto border border-stone-200 rounded-lg bg-white"
867
900
  style={{ height }}
868
901
  onKeyDown={handleKeyDown}
902
+ onScroll={handleScroll}
869
903
  tabIndex={0}
870
904
  >
871
905
  <table className="border-collapse" style={{ tableLayout: 'auto' }}>
@@ -974,18 +1008,23 @@ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
974
1008
 
975
1009
  {/* Body */}
976
1010
  <tbody>
977
- {filteredData.map((row, rowIndex) => {
978
- const isFrozen = isRowFrozen(rowIndex);
1011
+ {/* Top spacer for virtualization */}
1012
+ {virtualized && visibleRowRange.paddingTop > 0 && (
1013
+ <tr style={{ height: visibleRowRange.paddingTop }}>
1014
+ <td colSpan={columns.length + (rowHeaders ? 1 : 0)} />
1015
+ </tr>
1016
+ )}
1017
+
1018
+ {/* Render frozen rows first (always visible) */}
1019
+ {filteredData.slice(0, visibleRowRange.frozenRowCount).map((row, rowIndex) => {
979
1020
  const isZebra = zebraStripes && rowIndex % 2 === 1;
980
1021
 
981
1022
  return (
982
1023
  <tr
983
- key={rowIndex}
984
- className={`${isZebra ? 'bg-paper-50' : 'bg-white'} ${
985
- isFrozen ? 'sticky z-10' : ''
986
- } ${isFrozen ? 'shadow-sm' : ''}`}
1024
+ key={`frozen-${rowIndex}`}
1025
+ className={`${isZebra ? 'bg-paper-50' : 'bg-white'} sticky z-10 shadow-sm`}
987
1026
  style={{
988
- top: isFrozen ? `${40 + rowIndex * 40}px` : undefined,
1027
+ top: `${40 + rowIndex * virtualRowHeight}px`,
989
1028
  }}
990
1029
  >
991
1030
  {/* Row header */}
@@ -1023,6 +1062,7 @@ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
1023
1062
  style={{
1024
1063
  left: isFrozenCol ? leftOffset : undefined,
1025
1064
  minWidth: column?.minWidth || 80,
1065
+ height: virtualized ? virtualRowHeight : undefined,
1026
1066
  }}
1027
1067
  onClick={() => handleCellClick(rowIndex, colIndex)}
1028
1068
  onDoubleClick={(e) => handleCellDoubleClick(rowIndex, colIndex, e.currentTarget)}
@@ -1058,6 +1098,104 @@ export const DataGrid = forwardRef<DataGridHandle, DataGridProps>(
1058
1098
  </tr>
1059
1099
  );
1060
1100
  })}
1101
+
1102
+ {/* Render visible rows (virtualized or all) */}
1103
+ {filteredData
1104
+ .slice(
1105
+ virtualized ? Math.max(visibleRowRange.startIndex, visibleRowRange.frozenRowCount) : visibleRowRange.frozenRowCount,
1106
+ virtualized ? visibleRowRange.endIndex : filteredData.length
1107
+ )
1108
+ .map((row, idx) => {
1109
+ const rowIndex = virtualized
1110
+ ? Math.max(visibleRowRange.startIndex, visibleRowRange.frozenRowCount) + idx
1111
+ : visibleRowRange.frozenRowCount + idx;
1112
+ const isZebra = zebraStripes && rowIndex % 2 === 1;
1113
+
1114
+ return (
1115
+ <tr
1116
+ key={rowIndex}
1117
+ className={`${isZebra ? 'bg-paper-50' : 'bg-white'}`}
1118
+ style={{
1119
+ height: virtualized ? virtualRowHeight : undefined,
1120
+ }}
1121
+ >
1122
+ {/* Row header */}
1123
+ {rowHeaders && (
1124
+ <td
1125
+ className={`${cellPadding} border-b border-r border-stone-200 bg-stone-50 text-ink-500 font-medium sticky left-0 z-10`}
1126
+ style={{ width: 50, minWidth: 50, maxWidth: 50 }}
1127
+ >
1128
+ {Array.isArray(rowHeaders) ? rowHeaders[rowIndex] : rowIndex + 1}
1129
+ </td>
1130
+ )}
1131
+
1132
+ {/* Data cells */}
1133
+ {row.map((cell, colIndex) => {
1134
+ const column = columns[colIndex];
1135
+ const isFrozenCol = colIndex < frozenColumns;
1136
+ const isEditing =
1137
+ editingCell?.row === rowIndex && editingCell?.col === colIndex;
1138
+ const isSelected =
1139
+ selectedCell?.row === rowIndex && selectedCell?.col === colIndex;
1140
+ const hasFormula = !!cell?.formula;
1141
+ const leftOffset = rowHeaders
1142
+ ? 50 + columns.slice(0, colIndex).reduce((sum, c) => sum + (c.width || 100), 0)
1143
+ : columns.slice(0, colIndex).reduce((sum, c) => sum + (c.width || 100), 0);
1144
+ const cellBgClass = getCellBgClass(column, isZebra, isFrozenCol);
1145
+
1146
+ return (
1147
+ <td
1148
+ key={colIndex}
1149
+ className={`${cellPadding} border-b border-r border-stone-200 text-${
1150
+ column?.align || 'left'
1151
+ } ${isFrozenCol ? 'sticky z-10' : ''} ${cellBgClass} ${
1152
+ isSelected ? 'ring-2 ring-inset ring-primary-500' : ''
1153
+ } ${hasFormula ? 'bg-blue-50' : ''} ${cell?.className || ''}`}
1154
+ style={{
1155
+ left: isFrozenCol ? leftOffset : undefined,
1156
+ minWidth: column?.minWidth || 80,
1157
+ }}
1158
+ onClick={() => handleCellClick(rowIndex, colIndex)}
1159
+ onDoubleClick={(e) => handleCellDoubleClick(rowIndex, colIndex, e.currentTarget)}
1160
+ >
1161
+ {isEditing ? (
1162
+ formulas ? (
1163
+ <FormulaAutocomplete
1164
+ value={editValue}
1165
+ onChange={setEditValue}
1166
+ onComplete={handleEditComplete}
1167
+ onCancel={handleEditCancel}
1168
+ anchorRect={editingCellRect}
1169
+ autoFocus
1170
+ />
1171
+ ) : (
1172
+ <input
1173
+ ref={inputRef}
1174
+ type="text"
1175
+ value={editValue}
1176
+ onChange={(e) => setEditValue(e.target.value)}
1177
+ onBlur={handleEditComplete}
1178
+ onKeyDown={handleEditKeyDown}
1179
+ className="w-full h-full border-none outline-none bg-transparent"
1180
+ style={{ margin: '-4px', padding: '4px' }}
1181
+ />
1182
+ )
1183
+ ) : (
1184
+ formatValue(cell?.value, column)
1185
+ )}
1186
+ </td>
1187
+ );
1188
+ })}
1189
+ </tr>
1190
+ );
1191
+ })}
1192
+
1193
+ {/* Bottom spacer for virtualization */}
1194
+ {virtualized && visibleRowRange.paddingBottom > 0 && (
1195
+ <tr style={{ height: visibleRowRange.paddingBottom }}>
1196
+ <td colSpan={columns.length + (rowHeaders ? 1 : 0)} />
1197
+ </tr>
1198
+ )}
1061
1199
  </tbody>
1062
1200
  </table>
1063
1201
  </div>