ferns-ui 1.2.2 → 1.2.4

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.
@@ -0,0 +1,662 @@
1
+ import {FontAwesome6} from "@expo/vector-icons";
2
+ import React, {useCallback, useMemo, useRef, useState} from "react";
3
+ import {NativeScrollEvent, NativeSyntheticEvent, Pressable, ScrollView, View} from "react-native";
4
+ // @ts-ignore
5
+ import Markdown from "react-native-markdown-display";
6
+
7
+ import {Box} from "./Box";
8
+ import {
9
+ ColumnSortInterface,
10
+ DataTableCellData,
11
+ DataTableCellProps,
12
+ DataTableColumn,
13
+ DataTableCustomComponentMap,
14
+ DataTableProps,
15
+ SurfaceColor,
16
+ } from "./Common";
17
+ import {Icon} from "./Icon";
18
+ import {InfoModalIcon} from "./InfoModalIcon";
19
+ import {Modal} from "./Modal";
20
+ import {Pagination} from "./Pagination";
21
+ import {TableTitle} from "./table/TableTitle";
22
+ import {Text} from "./Text";
23
+ import {useTheme} from "./Theme";
24
+
25
+ // TODO: Add permanent horizontal scroll bar so users with only a mouse can scroll left/right
26
+ // easily.
27
+
28
+ const TextCell: React.FC<{
29
+ cellData: {value: string; textSize?: "sm" | "md" | "lg"};
30
+ column: DataTableColumn;
31
+ }> = ({cellData}) => {
32
+ return (
33
+ <Box flex="grow" justifyContent="center" paddingX={2}>
34
+ <Text size={cellData.textSize || "md"}>{cellData.value}</Text>
35
+ </Box>
36
+ );
37
+ };
38
+
39
+ const CheckedCell: React.FC<{cellData: {value: boolean}; column: DataTableColumn}> = ({
40
+ cellData,
41
+ }) => {
42
+ return (
43
+ <Box flex="grow" justifyContent="center" width="100%">
44
+ <Icon
45
+ color={cellData.value ? "success" : "secondaryDark"}
46
+ iconName={cellData.value ? "check" : "x"}
47
+ />
48
+ </Box>
49
+ );
50
+ };
51
+
52
+ const DataTableCell: React.FC<DataTableCellProps> = ({
53
+ value,
54
+ columnDef,
55
+ colIndex,
56
+ isPinnedHorizontal,
57
+ pinnedColumns,
58
+ columnWidths,
59
+ customColumnComponentMap,
60
+ backgroundColor,
61
+ height,
62
+ textSize = "md",
63
+ }) => {
64
+ const {theme} = useTheme();
65
+ const isLastPinnedColumn = isPinnedHorizontal && colIndex === pinnedColumns - 1;
66
+
67
+ // Default to TextCell
68
+ let Component: React.ComponentType<{
69
+ column: DataTableColumn;
70
+ cellData: {value: any; highlight?: SurfaceColor};
71
+ }> = TextCell;
72
+ if (customColumnComponentMap?.[columnDef.columnType]) {
73
+ Component = customColumnComponentMap[columnDef.columnType];
74
+ } else if (columnDef.columnType === "boolean") {
75
+ Component = CheckedCell;
76
+ }
77
+
78
+ return (
79
+ <View
80
+ style={{
81
+ padding: 16,
82
+ justifyContent: "center",
83
+ height,
84
+ width: columnDef.width,
85
+ backgroundColor,
86
+ overflow: "hidden",
87
+ position: "relative",
88
+ zIndex: 1,
89
+ borderBottomWidth: 1,
90
+ borderBottomColor: theme.border.default,
91
+ // For pinned columns: use absolute positioning to stay fixed while scrolling horizontally
92
+ ...(isPinnedHorizontal && {
93
+ position: "absolute",
94
+ // Position each pinned column by summing widths of all previous columns
95
+ left: columnWidths.slice(0, colIndex).reduce((sum, width) => sum + width, 0),
96
+ // Higher z-index keeps pinned columns above scrollable ones, decreasing by column index
97
+ zIndex: 10 - colIndex,
98
+ }),
99
+ // Visual separator after last pinned column
100
+ ...(isLastPinnedColumn && {
101
+ borderRightWidth: 3,
102
+ borderRightColor: theme.border.default,
103
+ }),
104
+ }}
105
+ >
106
+ <Component cellData={{...value, textSize}} column={columnDef} />
107
+ </View>
108
+ );
109
+ };
110
+
111
+ interface DataTableRowProps {
112
+ rowData: DataTableCellData[];
113
+ rowIndex: number;
114
+ columns: DataTableColumn[];
115
+ pinnedColumns: number;
116
+ columnWidths: number[];
117
+ alternateRowBackground: boolean;
118
+ customColumnComponentMap?: DataTableCustomComponentMap;
119
+ rowHeight: number;
120
+ }
121
+
122
+ const DataTableRow: React.FC<DataTableRowProps> = ({
123
+ rowData,
124
+ rowIndex,
125
+ columns,
126
+ pinnedColumns,
127
+ columnWidths,
128
+ alternateRowBackground,
129
+ customColumnComponentMap,
130
+ rowHeight,
131
+ }) => {
132
+ const {theme} = useTheme();
133
+ const backgroundColor =
134
+ alternateRowBackground && rowIndex % 2 === 1 ? theme.surface.neutralLight : theme.surface.base;
135
+
136
+ return (
137
+ <View
138
+ style={{
139
+ flexDirection: "row",
140
+ height: rowHeight,
141
+ borderBottomWidth: 1,
142
+ borderBottomColor: theme.border.default,
143
+ }}
144
+ >
145
+ {rowData.map((cell, colIndex) => (
146
+ <DataTableCell
147
+ key={colIndex}
148
+ backgroundColor={
149
+ cell.highlight ? theme.surface[cell.highlight as SurfaceColor] : backgroundColor
150
+ }
151
+ colIndex={colIndex}
152
+ columnDef={columns[colIndex]}
153
+ columnWidths={columnWidths}
154
+ customColumnComponentMap={customColumnComponentMap}
155
+ height={rowHeight}
156
+ isPinnedHorizontal={colIndex < pinnedColumns}
157
+ pinnedColumns={pinnedColumns}
158
+ textSize={cell.textSize}
159
+ value={cell}
160
+ />
161
+ ))}
162
+ </View>
163
+ );
164
+ };
165
+
166
+ interface MoreButtonCellProps {
167
+ rowIndex: number;
168
+ alternateRowBackground: boolean;
169
+ onClick: (rowIndex: number) => void;
170
+ column: DataTableColumn;
171
+ rowHeight: number;
172
+ }
173
+
174
+ const MoreButtonCell: React.FC<MoreButtonCellProps> = ({
175
+ rowIndex,
176
+ alternateRowBackground,
177
+ onClick,
178
+ rowHeight,
179
+ }) => {
180
+ const {theme} = useTheme();
181
+
182
+ return (
183
+ <View
184
+ style={{
185
+ width: 48,
186
+ height: rowHeight ?? 54,
187
+ justifyContent: "center",
188
+ alignItems: "center",
189
+ backgroundColor:
190
+ alternateRowBackground && rowIndex % 2 === 1
191
+ ? theme.surface.neutralLight
192
+ : theme.surface.base,
193
+ borderBottomWidth: 1,
194
+ borderBottomColor: theme.border.default,
195
+ }}
196
+ >
197
+ <Pressable
198
+ accessibilityHint="View details"
199
+ accessibilityLabel="Open modal"
200
+ accessibilityRole="button"
201
+ style={{
202
+ borderRadius: theme.radius.rounded,
203
+ backgroundColor:
204
+ alternateRowBackground && rowIndex % 2 === 1
205
+ ? theme.surface.base
206
+ : theme.surface.neutralLight,
207
+ width: 32,
208
+ height: 32,
209
+ justifyContent: "center",
210
+ alignItems: "center",
211
+ }}
212
+ onPress={() => onClick(rowIndex)}
213
+ >
214
+ <Icon color="secondaryDark" iconName="info" size="md" />
215
+ </Pressable>
216
+ </View>
217
+ );
218
+ };
219
+
220
+ interface DataTableHeaderCellProps {
221
+ column: DataTableColumn;
222
+ index: number;
223
+ isPinnedHorizontal: boolean;
224
+ isPinnedRow?: boolean;
225
+ columnWidths: number[];
226
+ sortColumn?: ColumnSortInterface;
227
+ onSort: (index: number) => void;
228
+ rowHeight: number;
229
+ }
230
+
231
+ const DataTableHeaderCell: React.FC<DataTableHeaderCellProps> = ({
232
+ column,
233
+ index,
234
+ isPinnedHorizontal,
235
+ columnWidths,
236
+ sortColumn,
237
+ onSort,
238
+ rowHeight,
239
+ }) => {
240
+ const {theme} = useTheme();
241
+ const sort = sortColumn?.column === index ? sortColumn.direction : undefined;
242
+
243
+ return (
244
+ <View
245
+ style={{
246
+ padding: 16,
247
+ flexDirection: "row",
248
+ alignItems: "center",
249
+ justifyContent: "space-between",
250
+ height: rowHeight ?? 54,
251
+ width: column.width,
252
+ backgroundColor: theme.surface.base,
253
+ borderBottomWidth: 1,
254
+ borderBottomColor: theme.border.default,
255
+ ...(isPinnedHorizontal && {
256
+ position: "absolute",
257
+ left: columnWidths.slice(0, index).reduce((sum, width) => sum + width, 0),
258
+ zIndex: 10 - index,
259
+ }),
260
+ }}
261
+ >
262
+ {Boolean(column.title) && <TableTitle align="left" title={column.title!} />}
263
+ <View style={{flexDirection: "row", alignItems: "center"}}>
264
+ {column.infoModalText && (
265
+ <InfoModalIcon infoModalChildren={<Markdown>{column.infoModalText}</Markdown>} />
266
+ )}
267
+ {column.sortable && (
268
+ <Pressable hitSlop={16} onPress={() => onSort(index)}>
269
+ <View
270
+ style={{
271
+ alignItems: "center",
272
+ backgroundColor: sort ? theme.surface.primary : theme.surface.neutralLight,
273
+ borderRadius: theme.radius.rounded,
274
+ justifyContent: "center",
275
+ height: 16,
276
+ width: 16,
277
+ marginLeft: 8,
278
+ }}
279
+ >
280
+ <FontAwesome6
281
+ color={theme.text.inverted}
282
+ name={
283
+ sort === "asc" ? "arrow-down" : sort === "desc" ? "arrow-up" : "arrows-up-down"
284
+ }
285
+ selectable={undefined}
286
+ size={10}
287
+ solid
288
+ />
289
+ </View>
290
+ </Pressable>
291
+ )}
292
+ </View>
293
+ </View>
294
+ );
295
+ };
296
+
297
+ interface DataTableHeaderProps {
298
+ columns: DataTableColumn[];
299
+ hasMoreContent: boolean;
300
+ pinnedColumns: number;
301
+ columnWidths: number[];
302
+ headerScrollRef: React.RefObject<ScrollView>;
303
+ sortColumn?: ColumnSortInterface;
304
+ onSort: (index: number) => void;
305
+ onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>, isHeader: boolean) => void;
306
+ rowHeight: number;
307
+ }
308
+
309
+ const DataTableHeader: React.FC<DataTableHeaderProps> = ({
310
+ columns,
311
+ hasMoreContent,
312
+ pinnedColumns,
313
+ columnWidths,
314
+ headerScrollRef,
315
+ sortColumn,
316
+ onSort,
317
+ onScroll,
318
+ rowHeight,
319
+ }) => {
320
+ const {theme} = useTheme();
321
+
322
+ return (
323
+ <View style={{flexDirection: "row", position: "relative"}}>
324
+ {/* Fixed-width container for "more" content button if present */}
325
+ {hasMoreContent && (
326
+ <View
327
+ style={{
328
+ width: 48,
329
+ height: rowHeight ?? 54,
330
+ backgroundColor: theme.surface.base,
331
+ borderBottomWidth: 1,
332
+ borderBottomColor: theme.border.default,
333
+ zIndex: 11,
334
+ }}
335
+ />
336
+ )}
337
+
338
+ {/* Container for pinned header columns - stays fixed during horizontal scroll */}
339
+ {pinnedColumns > 0 && (
340
+ <View
341
+ style={{
342
+ position: "absolute",
343
+ // Offset left position if there's a "more" content button
344
+ left: hasMoreContent ? 48 : 0,
345
+ top: 0,
346
+ zIndex: 10,
347
+ }}
348
+ >
349
+ {columns.slice(0, pinnedColumns).map((column, index) => (
350
+ <DataTableHeaderCell
351
+ key={`pinned-header-${index}`}
352
+ column={column}
353
+ columnWidths={columnWidths}
354
+ index={index}
355
+ isPinnedHorizontal
356
+ rowHeight={rowHeight}
357
+ sortColumn={sortColumn}
358
+ onSort={onSort}
359
+ />
360
+ ))}
361
+ </View>
362
+ )}
363
+
364
+ {/* Scrollable container for non-pinned header columns */}
365
+ <ScrollView
366
+ ref={headerScrollRef}
367
+ horizontal
368
+ scrollEventThrottle={16}
369
+ showsHorizontalScrollIndicator={false}
370
+ style={{
371
+ // Offset scrollable area by total width of pinned columns
372
+ marginLeft: columnWidths.slice(0, pinnedColumns).reduce((sum, width) => sum + width, 0),
373
+ }}
374
+ onScroll={(e) => onScroll(e, true)}
375
+ >
376
+ {columns.slice(pinnedColumns).map((column, index) => (
377
+ <DataTableHeaderCell
378
+ key={`scrollable-header-${index + pinnedColumns}`}
379
+ column={column}
380
+ columnWidths={columnWidths}
381
+ index={index + pinnedColumns}
382
+ isPinnedHorizontal={false}
383
+ rowHeight={rowHeight}
384
+ sortColumn={sortColumn}
385
+ onSort={onSort}
386
+ />
387
+ ))}
388
+ </ScrollView>
389
+ </View>
390
+ );
391
+ };
392
+
393
+ interface DataTableContentProps {
394
+ data: any[][];
395
+ columns: DataTableColumn[];
396
+ pinnedColumns: number;
397
+ alternateRowBackground: boolean;
398
+ columnWidths: number[];
399
+ bodyScrollRef: React.RefObject<ScrollView>;
400
+ onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>, isHeader: boolean) => void;
401
+ moreContentComponent?: React.ComponentType<{
402
+ column: DataTableColumn;
403
+ rowData: any[];
404
+ rowIndex: number;
405
+ }>;
406
+ // Extra props to pass to the more modal, one per row.
407
+ moreContentExtraData?: any[];
408
+ moreContentSize?: "sm" | "md" | "lg";
409
+ customColumnComponentMap?: DataTableCustomComponentMap;
410
+ rowHeight: number;
411
+ }
412
+
413
+ const DataTableContent: React.FC<DataTableContentProps> = ({
414
+ data,
415
+ columns,
416
+ pinnedColumns,
417
+ alternateRowBackground,
418
+ columnWidths,
419
+ bodyScrollRef,
420
+ onScroll,
421
+ customColumnComponentMap,
422
+ moreContentComponent: MoreContentContent,
423
+ moreContentExtraData,
424
+ moreContentSize = "md",
425
+ rowHeight,
426
+ }) => {
427
+ const [modalRow, setModalRow] = useState<number | null>(null);
428
+ const {theme} = useTheme();
429
+
430
+ return (
431
+ <>
432
+ <ScrollView style={{flex: 1}}>
433
+ <View
434
+ style={{
435
+ flexDirection: "row",
436
+ position: "relative",
437
+ }}
438
+ >
439
+ {/* Fixed-width container for "more" content button if present */}
440
+ {Boolean(MoreContentContent) && (
441
+ <View
442
+ style={{
443
+ position: "absolute",
444
+ left: 0,
445
+ top: 0,
446
+ width: 48,
447
+ zIndex: 1,
448
+ backgroundColor: theme.surface.base,
449
+ }}
450
+ >
451
+ {data.map((_, rowIndex) => (
452
+ <MoreButtonCell
453
+ key={`expand-${rowIndex}`}
454
+ alternateRowBackground={alternateRowBackground}
455
+ column={columns[0]}
456
+ rowHeight={rowHeight}
457
+ rowIndex={rowIndex}
458
+ onClick={setModalRow}
459
+ />
460
+ ))}
461
+ </View>
462
+ )}
463
+
464
+ {/* Container for pinned rows - stays fixed during horizontal scroll */}
465
+ {pinnedColumns > 0 && (
466
+ <View
467
+ style={{
468
+ position: "absolute",
469
+ left: Boolean(MoreContentContent) ? 48 : 0,
470
+ top: 0,
471
+ zIndex: 10,
472
+ }}
473
+ >
474
+ {data.map((row, rowIndex) => (
475
+ <DataTableRow
476
+ key={`pinned-${rowIndex}`}
477
+ alternateRowBackground={alternateRowBackground}
478
+ columnWidths={columnWidths}
479
+ columns={columns.slice(0, pinnedColumns)}
480
+ customColumnComponentMap={customColumnComponentMap}
481
+ pinnedColumns={pinnedColumns}
482
+ rowData={row.slice(0, pinnedColumns)}
483
+ rowHeight={rowHeight}
484
+ rowIndex={rowIndex}
485
+ />
486
+ ))}
487
+ </View>
488
+ )}
489
+
490
+ {/* Scrollable container for non-pinned rows */}
491
+ <ScrollView
492
+ ref={bodyScrollRef}
493
+ horizontal
494
+ scrollEventThrottle={16}
495
+ showsHorizontalScrollIndicator
496
+ style={{
497
+ flex: 1,
498
+ marginLeft:
499
+ columnWidths.slice(0, pinnedColumns).reduce((sum, width) => sum + width, 0) +
500
+ (Boolean(MoreContentContent) ? 48 : 0),
501
+ }}
502
+ onScroll={(e) => onScroll(e, false)}
503
+ >
504
+ <View>
505
+ {data.map((row, rowIndex) => (
506
+ <DataTableRow
507
+ key={`scrollable-${rowIndex}`}
508
+ alternateRowBackground={alternateRowBackground}
509
+ columnWidths={columnWidths}
510
+ columns={columns.slice(pinnedColumns)}
511
+ customColumnComponentMap={customColumnComponentMap}
512
+ pinnedColumns={0}
513
+ rowData={row.slice(pinnedColumns)}
514
+ rowHeight={rowHeight}
515
+ rowIndex={rowIndex}
516
+ />
517
+ ))}
518
+ </View>
519
+ </ScrollView>
520
+ </View>
521
+ </ScrollView>
522
+
523
+ {MoreContentContent && (
524
+ <Modal
525
+ size={moreContentSize}
526
+ visible={modalRow !== null}
527
+ onDismiss={() => setModalRow(null)}
528
+ >
529
+ <MoreContentContent
530
+ column={columns[0]}
531
+ rowData={data[modalRow!]}
532
+ rowIndex={modalRow!}
533
+ {...(moreContentExtraData?.[modalRow!] ?? {})}
534
+ />
535
+ </Modal>
536
+ )}
537
+ </>
538
+ );
539
+ };
540
+
541
+ export const DataTable: React.FC<DataTableProps> = ({
542
+ data,
543
+ columns,
544
+ alternateRowBackground = true,
545
+ totalPages = 1,
546
+ page = 0,
547
+ setPage,
548
+ pinnedColumns = 0,
549
+ sortColumn,
550
+ setSortColumn,
551
+ moreContentComponent,
552
+ moreContentExtraData,
553
+ customColumnComponentMap,
554
+ rowHeight = 54,
555
+ defaultTextSize = "md",
556
+ }) => {
557
+ const {theme} = useTheme();
558
+ const headerScrollRef = useRef<ScrollView>(null);
559
+ const bodyScrollRef = useRef<ScrollView>(null);
560
+
561
+ const columnWidths = useMemo(() => columns.map((col) => col.width), [columns]);
562
+
563
+ const handleScroll = useCallback(
564
+ (event: NativeSyntheticEvent<NativeScrollEvent>, isHeader: boolean) => {
565
+ const scrollX = event.nativeEvent.contentOffset.x;
566
+ if (isHeader && bodyScrollRef.current) {
567
+ bodyScrollRef.current.scrollTo({x: scrollX, animated: false});
568
+ } else if (!isHeader && headerScrollRef.current) {
569
+ headerScrollRef.current.scrollTo({x: scrollX, animated: false});
570
+ }
571
+ },
572
+ []
573
+ );
574
+
575
+ const handleSort = useCallback(
576
+ (columnIndex: number) => {
577
+ if (!setSortColumn || !columns[columnIndex].sortable) {
578
+ return;
579
+ }
580
+
581
+ if (sortColumn?.column === columnIndex) {
582
+ if (sortColumn.direction === "asc") {
583
+ setSortColumn({
584
+ column: columnIndex,
585
+ direction: "desc",
586
+ });
587
+ } else {
588
+ setSortColumn(undefined);
589
+ }
590
+ } else {
591
+ setSortColumn({
592
+ column: columnIndex,
593
+ direction: "asc",
594
+ });
595
+ }
596
+ },
597
+ [sortColumn, setSortColumn, columns]
598
+ );
599
+
600
+ const processedData = useMemo(() => {
601
+ return data.map((row) =>
602
+ row.map((cell) => ({
603
+ ...cell,
604
+ textSize: cell.textSize || defaultTextSize,
605
+ }))
606
+ );
607
+ }, [data, defaultTextSize]);
608
+
609
+ return (
610
+ <View style={{height: "100%", display: "flex", flexDirection: "column"}}>
611
+ <View
612
+ style={{
613
+ flex: 1,
614
+ minHeight: 0,
615
+ borderWidth: 1,
616
+ borderColor: theme.border.default,
617
+ height: "100%",
618
+ }}
619
+ >
620
+ <DataTableHeader
621
+ columnWidths={columnWidths}
622
+ columns={columns}
623
+ hasMoreContent={Boolean(moreContentComponent)}
624
+ headerScrollRef={headerScrollRef}
625
+ pinnedColumns={pinnedColumns}
626
+ rowHeight={rowHeight}
627
+ sortColumn={sortColumn}
628
+ onScroll={handleScroll}
629
+ onSort={handleSort}
630
+ />
631
+
632
+ <View style={{flex: 1, minHeight: 0}}>
633
+ <DataTableContent
634
+ alternateRowBackground={alternateRowBackground}
635
+ bodyScrollRef={bodyScrollRef}
636
+ columnWidths={columnWidths}
637
+ columns={columns}
638
+ customColumnComponentMap={customColumnComponentMap}
639
+ data={processedData}
640
+ moreContentComponent={moreContentComponent}
641
+ moreContentExtraData={moreContentExtraData}
642
+ pinnedColumns={pinnedColumns}
643
+ rowHeight={rowHeight}
644
+ onScroll={handleScroll}
645
+ />
646
+ </View>
647
+ </View>
648
+
649
+ {Boolean(setPage && totalPages > 1) && (
650
+ <View
651
+ style={{
652
+ height: 60,
653
+ padding: 16,
654
+ alignItems: "center",
655
+ }}
656
+ >
657
+ <Pagination page={page} setPage={setPage!} totalPages={totalPages} />
658
+ </View>
659
+ )}
660
+ </View>
661
+ );
662
+ };
@@ -115,13 +115,17 @@ const IconButtonComponent: FC<IconButtonProps> = ({
115
115
  }}
116
116
  testID={testID}
117
117
  onPress={debounce(
118
+ // TODO: Allow for a click outside of the confirmation modal to close it.
118
119
  async () => {
119
120
  await Unifier.utils.haptic();
120
121
  setLoading(true);
121
122
  try {
123
+ // If a confirmation is required, and the confirmation modal is not currently open,
124
+ // open it
122
125
  if (withConfirmation && !showConfirmation) {
123
126
  setShowConfirmation(true);
124
- } else if (onClick) {
127
+ } else if (!withConfirmation && onClick) {
128
+ // If a confirmation is not required, perform the action.
125
129
  await onClick();
126
130
  }
127
131
  } catch (error) {