@beppla/tapas-ui 1.4.6 → 1.4.8

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.
@@ -8,10 +8,12 @@ A versatile table component for displaying matrix data with optional row and col
8
8
  - ✅ Row statistics (Sum/Mean columns)
9
9
  - ✅ Column statistics (Sum/Mean rows)
10
10
  - ✅ Horizontal and vertical scrolling
11
- - ✅ Pagination support
11
+ - ✅ Virtual scrolling for large datasets (high performance)
12
+ - ✅ External pagination support
12
13
  - ✅ Loading state
13
14
  - ✅ Empty state
14
15
  - ✅ Placeholder rows and columns (fill container when data is insufficient)
16
+ - ✅ Internationalization (i18n) support
15
17
  - ✅ Customizable styling
16
18
  - ✅ TypeScript support
17
19
  - ✅ Cross-platform (Web & Mobile)
@@ -87,22 +89,43 @@ const cells = [
87
89
  />
88
90
  ```
89
91
 
90
- ## With Pagination
92
+ ## With External Pagination
93
+
94
+ The StatisticsTable component doesn't include built-in pagination. Use the separate Pagination component for better layout control:
91
95
 
92
96
  ```tsx
93
- <StatisticsTable
94
- rows={rows}
95
- columns={columns}
96
- cells={cells}
97
- pagination={{
98
- current: 1,
99
- pageSize: 10,
100
- total: 100,
101
- onChange: (page, pageSize) => {
102
- console.log('Page:', page, 'PageSize:', pageSize);
103
- },
104
- }}
105
- />
97
+ import { StatisticsTable, Pagination } from '@beppla/tapas-ui';
98
+
99
+ function MyTable() {
100
+ const [currentPage, setCurrentPage] = useState(1);
101
+ const pageSize = 10;
102
+
103
+ // Calculate paginated rows
104
+ const paginatedRows = rows.slice(
105
+ (currentPage - 1) * pageSize,
106
+ currentPage * pageSize
107
+ );
108
+
109
+ return (
110
+ <>
111
+ <StatisticsTable
112
+ rows={paginatedRows}
113
+ columns={columns}
114
+ cells={cells}
115
+ />
116
+ <Pagination
117
+ totalCount={`${rows.length} items`}
118
+ pageParams={{
119
+ page: currentPage,
120
+ pageSize: pageSize,
121
+ totalItems: rows.length,
122
+ totalPage: Math.ceil(rows.length / pageSize),
123
+ }}
124
+ onPageChange={(params) => setCurrentPage(params.page)}
125
+ />
126
+ </>
127
+ );
128
+ }
106
129
  ```
107
130
 
108
131
  ## With Placeholder Rows and Columns
@@ -134,11 +157,34 @@ You can control the size of placeholder rows and columns:
134
157
  minRows={10}
135
158
  minColumns={5}
136
159
  placeholderRowHeight={80} // Each placeholder row will be 80px tall
137
- placeholderColumnWidth={200} // Each placeholder column will be 200px wide
160
+ placeholderColumnWidth={200} // All placeholder columns will be 200px wide
138
161
  maxHeight={600}
139
162
  />
140
163
  ```
141
164
 
165
+ **Individual placeholder column widths** for better screen adaptation:
166
+
167
+ ```tsx
168
+ <StatisticsTable
169
+ rows={rows} // 2 data columns
170
+ columns={columns}
171
+ cells={cells}
172
+ enablePlaceholder={true}
173
+ minColumns={5} // Need 3 placeholder columns
174
+ placeholderColumnWidth={[180, 220, 150]} // Individual widths for each placeholder
175
+ // Placeholder column 1: 180px
176
+ // Placeholder column 2: 220px
177
+ // Placeholder column 3: 150px
178
+ />
179
+ ```
180
+
181
+ If array length is less than placeholder columns needed, the last value will be reused:
182
+
183
+ ```tsx
184
+ placeholderColumnWidth={[200, 150]} // minColumns=5, need 3 placeholders
185
+ // Result: [200, 150, 150] - last value (150) reused
186
+ ```
187
+
142
188
  **How it works:**
143
189
  - If `rows.length < minRows`: Adds `(minRows - rows.length)` empty placeholder rows
144
190
  - If columns count `< minColumns`: Adds `(minColumns - columns.count)` empty placeholder columns
@@ -176,6 +222,102 @@ Scroll shadow features:
176
222
  - Shadows automatically show/hide based on scroll position
177
223
  - Works seamlessly with row statistics and column statistics
178
224
 
225
+ ## Virtual Scrolling for Large Datasets
226
+
227
+ For tables with hundreds or thousands of rows, enable virtual scrolling for optimal performance:
228
+
229
+ ```tsx
230
+ <StatisticsTable
231
+ rows={thousandsOfRows}
232
+ columns={columns}
233
+ cells={cells}
234
+ enableVirtualization={true}
235
+ virtualRowHeight={56} // Default row height for calculation
236
+ maxHeight={600}
237
+ />
238
+ ```
239
+
240
+ **How it works:**
241
+ - Only renders rows visible in the viewport (plus a small buffer)
242
+ - Automatically calculates which rows to render based on scroll position
243
+ - Maintains proper scroll behavior with placeholder spaces above/below
244
+ - Supports dynamic row heights via `getRowHeight` prop
245
+ - Typical performance: 10,000+ rows scroll smoothly
246
+
247
+ **Performance tips:**
248
+ - Set `virtualRowHeight` to match your average row height
249
+ - Use `getRowHeight` for dynamic heights, but be aware it's called frequently
250
+ - For uniform row heights, use the default `rowHeight` for best performance
251
+
252
+ **Example with dynamic heights:**
253
+
254
+ ```tsx
255
+ <StatisticsTable
256
+ rows={largeDataset}
257
+ columns={columns}
258
+ cells={cells}
259
+ enableVirtualization={true}
260
+ getRowHeight={(rowKey, rowData) => {
261
+ // Return height based on row data
262
+ return rowData?.isExpanded ? 120 : 56;
263
+ }}
264
+ maxHeight={600}
265
+ />
266
+ ```
267
+
268
+ ## Internationalization (i18n)
269
+
270
+ Customize loading and empty state texts for different languages:
271
+
272
+ ```tsx
273
+ // English
274
+ <StatisticsTable
275
+ rows={rows}
276
+ columns={columns}
277
+ cells={cells}
278
+ loading={isLoading}
279
+ loadingText="Loading..."
280
+ emptyText="No data available"
281
+ />
282
+
283
+ // Chinese
284
+ <StatisticsTable
285
+ rows={rows}
286
+ columns={columns}
287
+ cells={cells}
288
+ loading={isLoading}
289
+ loadingText="加载中..."
290
+ emptyText="暂无数据"
291
+ />
292
+
293
+ // Spanish
294
+ <StatisticsTable
295
+ rows={rows}
296
+ columns={columns}
297
+ cells={cells}
298
+ loading={isLoading}
299
+ loadingText="Cargando..."
300
+ emptyText="No hay datos"
301
+ />
302
+
303
+ // With i18n library
304
+ import { useTranslation } from 'react-i18next';
305
+
306
+ function MyTable() {
307
+ const { t } = useTranslation();
308
+
309
+ return (
310
+ <StatisticsTable
311
+ rows={rows}
312
+ columns={columns}
313
+ cells={cells}
314
+ loadingText={t('table.loading')}
315
+ emptyText={t('table.empty')}
316
+ />
317
+ );
318
+ }
319
+ ```
320
+
179
321
  ## Props
180
322
 
181
323
  | Prop | Type | Default | Description |
@@ -185,16 +327,19 @@ Scroll shadow features:
185
327
  | `cells` | `StatisticsTableCell[]` | Required | Cell data |
186
328
  | `showRowStats` | `boolean` | `false` | Show row statistics (Sum/Mean columns) |
187
329
  | `showColumnStats` | `boolean` | `false` | Show column statistics (Sum/Mean rows) |
188
- | `pagination` | `PaginationConfig \| false` | `false` | Pagination configuration |
189
330
  | `loading` | `boolean` | `false` | Show loading state |
190
- | `emptyText` | `string` | `'No data'` | Text for empty state |
331
+ | `loadingText` | `string` | `'Loading...'` | Text to display during loading (for i18n) |
332
+ | `emptyText` | `string` | `'No data'` | Text to display when empty (for i18n) |
191
333
  | `maxHeight` | `number` | `500` | Maximum table body height |
192
334
  | `rowLabelWidth` | `number` | `150` | Width of first column (row labels) |
193
335
  | `enablePlaceholder` | `boolean` | `false` | Enable placeholder rows/columns to fill container |
194
336
  | `minRows` | `number` | `0` | Minimum number of rows (adds placeholders if needed) |
195
337
  | `minColumns` | `number` | `0` | Minimum number of columns (adds placeholders if needed) |
196
338
  | `placeholderRowHeight` | `number` | `rowHeight` (56) | Height of each placeholder row |
197
- | `placeholderColumnWidth` | `number` | `150` | Width of each placeholder column |
339
+ | `placeholderColumnWidth` | `number \| number[]` | `150` | Width of placeholder columns (single number or array for each column) |
340
+ | `enableVirtualization` | `boolean` | `false` | Enable virtual scrolling for large datasets |
341
+ | `virtualRowHeight` | `number` | `56` | Average row height for virtual scroll calculations |
342
+ | `getRowHeight` | `(rowKey, rowData) => number` | - | Dynamic row height function (used in virtual scrolling) |
198
343
  | `enableScrollShadow` | `boolean` | `true` | Enable scroll shadows on all four sides |
199
344
  | `showScrollIndicator` | `boolean` | `false` | Show scroll indicators/scrollbars |
200
345
  | `style` | `ViewStyle` | - | Custom container styles |
@@ -214,13 +359,6 @@ interface StatisticsTableCell {
214
359
  quantity: number;
215
360
  amount: number;
216
361
  }
217
-
218
- interface PaginationConfig {
219
- current: number;
220
- pageSize: number;
221
- total: number;
222
- onChange?: (page: number, pageSize: number) => void;
223
- }
224
362
  ```
225
363
 
226
364
  ## Use Cases
@@ -3,7 +3,6 @@
3
3
  import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react';
4
4
  import { View, ScrollView, StyleSheet, Platform, TouchableOpacity, Text, Animated } from 'react-native';
5
5
  import { useTheme } from '@rneui/themed';
6
- import Pagination from '../Pagination/Pagination';
7
6
  import Hoverable from '../Hoverable/Hoverable';
8
7
 
9
8
  // Tooltip 内容类型
@@ -86,8 +85,8 @@ export function StatisticsTable({
86
85
  cells,
87
86
  showRowStats = false,
88
87
  showColumnStats = false,
89
- pagination,
90
88
  loading = false,
89
+ loadingText = 'Loading...',
91
90
  emptyText = 'No data',
92
91
  maxHeight = 500,
93
92
  rowLabelWidth = 150,
@@ -146,6 +145,9 @@ export function StatisticsTable({
146
145
  isAtBottom: false
147
146
  });
148
147
 
148
+ // 虚拟滚动状态
149
+ const [virtualScrollOffset, setVirtualScrollOffset] = useState(0);
150
+
149
151
  // Hover 状态
150
152
  const [hoveredCell, setHoveredCell] = useState(null);
151
153
  const scrollViewRef = useRef(null);
@@ -188,12 +190,13 @@ export function StatisticsTable({
188
190
  backgroundColor: colors.colorSurface // 米色背景
189
191
  },
190
192
  header: {
191
- backgroundColor: headerStyle?.backgroundColor || colors.colorSurface,
192
- // 米色
193
+ backgroundColor: headerStyle?.backgroundColor || colors.colorSurface7,
194
+ // rgba(0, 0, 0, 0.08)
193
195
  borderBottomColor: colors.colorTableBorder
194
196
  },
195
197
  headerCell: {
196
- borderRightColor: colors.colorTableBorder
198
+ borderRightColor: colors.colorTableBorder,
199
+ backgroundColor: headerStyle?.backgroundColor || colors.colorSurface7
197
200
  },
198
201
  headerText: {
199
202
  color: headerStyle?.textColor || colors.colorTextPrimary,
@@ -202,7 +205,7 @@ export function StatisticsTable({
202
205
  },
203
206
  // Row Stats 表头样式
204
207
  statsHeaderCell: {
205
- backgroundColor: rowStatsHeaderStyle?.backgroundColor || 'rgba(0, 0, 0, 0.08)',
208
+ backgroundColor: rowStatsHeaderStyle?.backgroundColor || colors.colorSurface7,
206
209
  borderRightColor: colors.colorTableBorder,
207
210
  borderBottomColor: colors.colorTableBorder
208
211
  },
@@ -255,10 +258,6 @@ export function StatisticsTable({
255
258
  fontSize: columnStatsLabelStyle?.fontSize || 14,
256
259
  fontWeight: columnStatsLabelStyle?.fontWeight || '600'
257
260
  },
258
- paginationContainer: {
259
- borderTopColor: colors.colorTableBorder,
260
- backgroundColor: colors.colorSurface
261
- },
262
261
  // Column Stats 分隔线
263
262
  columnStatsDivider: {
264
263
  borderTopWidth: 2,
@@ -344,11 +343,15 @@ export function StatisticsTable({
344
343
  // 添加占位列来填充
345
344
  const placeholderColumns = Array.from({
346
345
  length: missingColumns
347
- }, (_, i) => ({
348
- key: `_placeholder_col_${i}`,
349
- title: '',
350
- width: placeholderColumnWidth // 使用自定义占位列宽度
351
- }));
346
+ }, (_, i) => {
347
+ // 支持数组形式的宽度设置
348
+ const width = Array.isArray(placeholderColumnWidth) ? placeholderColumnWidth[i] || placeholderColumnWidth[placeholderColumnWidth.length - 1] || 150 : placeholderColumnWidth;
349
+ return {
350
+ key: `_placeholder_col_${i}`,
351
+ title: '',
352
+ width: width
353
+ };
354
+ });
352
355
  finalColumns = [...columns, ...placeholderColumns];
353
356
  }
354
357
 
@@ -407,8 +410,13 @@ export function StatisticsTable({
407
410
  y: contentOffset.y,
408
411
  animated: false
409
412
  });
413
+
414
+ // 更新虚拟滚动偏移量
415
+ if (enableVirtualization) {
416
+ setVirtualScrollOffset(contentOffset.y);
417
+ }
410
418
  handleScroll(event);
411
- }, [handleScroll]);
419
+ }, [handleScroll, enableVirtualization]);
412
420
 
413
421
  // 处理单元格 hover
414
422
  const handleCellHover = useCallback((rowKey, columnKey, isHovered) => {
@@ -517,7 +525,10 @@ export function StatisticsTable({
517
525
  children: /*#__PURE__*/_jsx(View, {
518
526
  style: styles.loadingRow,
519
527
  children: /*#__PURE__*/_jsx(Text, {
520
- children: "Loading..."
528
+ style: {
529
+ color: colors.colorTextPlaceholder
530
+ },
531
+ children: loadingText
521
532
  })
522
533
  })
523
534
  });
@@ -536,7 +547,75 @@ export function StatisticsTable({
536
547
  })
537
548
  });
538
549
  }
539
- const visibleRows = enableVirtualization ? displayRows.slice(0, Math.ceil(maxHeight / virtualRowHeight)) : displayRows;
550
+
551
+ // 虚拟滚动计算
552
+ const virtualScrollData = useMemo(() => {
553
+ if (!enableVirtualization) {
554
+ return {
555
+ visibleRows: displayRows,
556
+ startIndex: 0,
557
+ endIndex: displayRows.length,
558
+ offsetTop: 0,
559
+ offsetBottom: 0,
560
+ totalHeight: 0
561
+ };
562
+ }
563
+
564
+ // 计算每行的累积高度
565
+ const rowHeights = [];
566
+ let totalHeight = 0;
567
+ displayRows.forEach(row => {
568
+ const height = row.height || (getRowHeight ? getRowHeight(row.key, row.data) : rowHeight || virtualRowHeight);
569
+ rowHeights.push(height);
570
+ totalHeight += height;
571
+ });
572
+
573
+ // 计算可视区域
574
+ const viewportHeight = maxHeight - (actualShowColumnStats ? 120 : 0);
575
+ const scrollTop = virtualScrollOffset;
576
+
577
+ // 查找起始索引
578
+ let startIndex = 0;
579
+ let accumulatedHeight = 0;
580
+ for (let i = 0; i < rowHeights.length; i++) {
581
+ const currentHeight = rowHeights[i] || virtualRowHeight;
582
+ if (accumulatedHeight + currentHeight > scrollTop) {
583
+ startIndex = i;
584
+ break;
585
+ }
586
+ accumulatedHeight += currentHeight;
587
+ }
588
+
589
+ // 添加缓冲区(上下各多渲染几行)
590
+ const overscan = 3;
591
+ startIndex = Math.max(0, startIndex - overscan);
592
+
593
+ // 查找结束索引
594
+ let endIndex = startIndex;
595
+ let visibleHeight = 0;
596
+ for (let i = startIndex; i < rowHeights.length; i++) {
597
+ const currentHeight = rowHeights[i] || virtualRowHeight;
598
+ visibleHeight += currentHeight;
599
+ endIndex = i + 1;
600
+ if (visibleHeight >= viewportHeight) {
601
+ break;
602
+ }
603
+ }
604
+ endIndex = Math.min(displayRows.length, endIndex + overscan);
605
+
606
+ // 计算上下占位空间
607
+ const offsetTop = rowHeights.slice(0, startIndex).reduce((sum, h) => sum + h, 0);
608
+ const offsetBottom = rowHeights.slice(endIndex).reduce((sum, h) => sum + h, 0);
609
+ return {
610
+ visibleRows: displayRows.slice(startIndex, endIndex),
611
+ startIndex,
612
+ endIndex,
613
+ offsetTop,
614
+ offsetBottom,
615
+ totalHeight
616
+ };
617
+ }, [enableVirtualization, displayRows, virtualScrollOffset, maxHeight, actualShowColumnStats, rowHeight, getRowHeight]);
618
+ const visibleRows = virtualScrollData.visibleRows;
540
619
 
541
620
  // Web 阴影样式
542
621
  const webShadowStyle = Platform.OS === 'web' ? {
@@ -610,7 +689,7 @@ export function StatisticsTable({
610
689
  })
611
690
  }, column.key);
612
691
  })]
613
- }), /*#__PURE__*/_jsx(ScrollView, {
692
+ }), /*#__PURE__*/_jsxs(ScrollView, {
614
693
  ref: bodyScrollViewRef,
615
694
  style: {
616
695
  maxHeight: maxHeight - columnStatsHeight
@@ -618,15 +697,24 @@ export function StatisticsTable({
618
697
  showsVerticalScrollIndicator: showScrollIndicator,
619
698
  onScroll: handleBodyScroll,
620
699
  scrollEventThrottle: 16,
621
- children: visibleRows.map(row => {
700
+ children: [enableVirtualization && virtualScrollData.offsetTop > 0 && /*#__PURE__*/_jsx(View, {
701
+ style: {
702
+ height: virtualScrollData.offsetTop
703
+ }
704
+ }), visibleRows.map((row, rowIndex) => {
622
705
  const currentRowHeight = getRowHeightValue(row);
623
706
  const isClickable = !!onRowPress;
624
707
  const isRowHovered = hoveredCell?.rowKey === row.key;
625
708
  const isPlaceholder = row.key.startsWith('_placeholder_row_');
709
+ const isLastRow = rowIndex === visibleRows.length - 1;
626
710
  return /*#__PURE__*/_jsxs(TouchableOpacity, {
627
711
  style: [styles.row, themedStyles.row, {
628
712
  minHeight: currentRowHeight
629
- }, isRowHovered && !isPlaceholder && themedStyles.rowHovered],
713
+ }, isRowHovered && !isPlaceholder && themedStyles.rowHovered,
714
+ // 最后一行且没有 Column Stats 时移除底部边框,避免与表格底边重合
715
+ isLastRow && !actualShowColumnStats && {
716
+ borderBottomWidth: 0
717
+ }],
630
718
  onPress: isClickable && !isPlaceholder ? () => onRowPress(row.key, row.data) : undefined,
631
719
  activeOpacity: isClickable && !isPlaceholder ? 0.7 : 1,
632
720
  disabled: !isClickable || isPlaceholder,
@@ -670,7 +758,11 @@ export function StatisticsTable({
670
758
  }, column.key);
671
759
  })]
672
760
  }, row.key);
673
- })
761
+ }), enableVirtualization && virtualScrollData.offsetBottom > 0 && /*#__PURE__*/_jsx(View, {
762
+ style: {
763
+ height: virtualScrollData.offsetBottom
764
+ }
765
+ })]
674
766
  }), actualShowColumnStats && columnStats && /*#__PURE__*/_jsxs(Animated.View, {
675
767
  style: [styles.columnStatsContainer, themedStyles.columnStatsDivider, enableStatsAnimation && {
676
768
  opacity: columnStatsAnimation,
@@ -820,15 +912,21 @@ export function StatisticsTable({
820
912
  children: rowStatsHeaders.mean
821
913
  })
822
914
  })]
823
- }), /*#__PURE__*/_jsx(ScrollView, {
915
+ }), /*#__PURE__*/_jsxs(ScrollView, {
824
916
  ref: rowStatsScrollRef,
825
917
  style: {
826
918
  maxHeight: maxHeight - columnStatsHeight
827
919
  },
828
920
  showsVerticalScrollIndicator: false,
829
921
  scrollEnabled: false,
830
- children: visibleRows.map((row, rowIndex) => {
831
- const rowStat = rowStats?.[rowIndex];
922
+ children: [enableVirtualization && virtualScrollData.offsetTop > 0 && /*#__PURE__*/_jsx(View, {
923
+ style: {
924
+ height: virtualScrollData.offsetTop
925
+ }
926
+ }), visibleRows.map((row, rowIndex) => {
927
+ // 计算在原始数组中的索引
928
+ const actualIndex = enableVirtualization ? virtualScrollData.startIndex + rowIndex : rowIndex;
929
+ const rowStat = rowStats?.[actualIndex];
832
930
  const currentRowHeight = getRowHeightValue(row);
833
931
  const isRowHovered = hoveredCell?.rowKey === row.key;
834
932
  const isSumHovered = hoveredCell?.rowKey === row.key && hoveredCell?.columnKey === '__row_sum__';
@@ -879,23 +977,13 @@ export function StatisticsTable({
879
977
  })
880
978
  })]
881
979
  }, row.key);
882
- })
980
+ }), enableVirtualization && virtualScrollData.offsetBottom > 0 && /*#__PURE__*/_jsx(View, {
981
+ style: {
982
+ height: virtualScrollData.offsetBottom
983
+ }
984
+ })]
883
985
  })]
884
986
  })]
885
- }), pagination !== false && pagination && /*#__PURE__*/_jsx(View, {
886
- style: [styles.paginationContainer, themedStyles.paginationContainer],
887
- children: /*#__PURE__*/_jsx(Pagination, {
888
- totalCount: `${pagination.total} items`,
889
- pageParams: {
890
- page: pagination.current,
891
- pageSize: pagination.pageSize,
892
- totalItems: pagination.total,
893
- totalPage: Math.ceil(pagination.total / pagination.pageSize)
894
- },
895
- onPageChange: params => {
896
- pagination.onChange?.(params.page, params.pageSize);
897
- }
898
- })
899
987
  })]
900
988
  });
901
989
  }
@@ -984,9 +1072,6 @@ const styles = StyleSheet.create({
984
1072
  emptyText: {
985
1073
  fontSize: 14
986
1074
  },
987
- paginationContainer: {
988
- borderTopWidth: 1
989
- },
990
1075
  // Row Stats 容器
991
1076
  rowStatsContainer: {
992
1077
  position: 'relative'
@@ -1004,11 +1089,13 @@ const styles = StyleSheet.create({
1004
1089
  },
1005
1090
  shadowLeft: {
1006
1091
  left: 0,
1007
- top: 0,
1092
+ top: 40,
1093
+ // 从表头底部开始
1008
1094
  bottom: 0
1009
1095
  },
1010
1096
  shadowRight: {
1011
- top: 0,
1097
+ top: 40,
1098
+ // 从表头底部开始
1012
1099
  bottom: 0
1013
1100
  },
1014
1101
  // 水平阴影条
@@ -1021,7 +1108,7 @@ const styles = StyleSheet.create({
1021
1108
  backgroundColor: 'transparent'
1022
1109
  },
1023
1110
  shadowTop: {
1024
- top: 56 // header height
1111
+ top: 40 // header height (paddingVertical: 12*2 + content ~16)
1025
1112
  },
1026
1113
  shadowBottom: {
1027
1114
  // bottom 由动态样式设置