fluent-styles 1.60.0 → 1.62.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.
@@ -0,0 +1,843 @@
1
+ import React, { useState, useCallback, type ReactNode } from "react";
2
+ import {
3
+ ScrollView,
4
+ FlatList,
5
+ useWindowDimensions,
6
+ } from "react-native";
7
+ import { Stack } from "../stack";
8
+ import { StyledText } from "../text";
9
+ import { StyledCheckBox } from "../checkBox";
10
+ import { StyledPressable } from "../pressable";
11
+ import { theme, palettes } from "../utiles/theme";
12
+
13
+ // ─── CompatNode ───────────────────────────────────────────────────────────────
14
+ type CompatNode = ReactNode;
15
+
16
+ // ─── Column definition ────────────────────────────────────────────────────────
17
+
18
+ export type ColumnAlign = "left" | "center" | "right";
19
+ export type SortDirection = "asc" | "desc" | null;
20
+
21
+ export interface TableColumn<T = any> {
22
+ /** Unique key — must match a key of the row data object */
23
+ key: string;
24
+ /** Header label */
25
+ title: string;
26
+ /** Fixed column width in px. If omitted the column stretches (flex: 1). */
27
+ width?: number;
28
+ /** Text alignment within the column — default "left" */
29
+ align?: ColumnAlign;
30
+ /** Allow sorting on this column */
31
+ sortable?: boolean;
32
+ /** Custom cell renderer. Receives the cell value + full row object + row index. */
33
+ render?: (value: any, row: T, index: number) => CompatNode;
34
+ }
35
+
36
+ // ─── Row data ─────────────────────────────────────────────────────────────────
37
+
38
+ export interface TableRow {
39
+ /** Must be unique across all rows */
40
+ id: string | number;
41
+ [key: string]: any;
42
+ }
43
+
44
+ // ─── Color tokens ─────────────────────────────────────────────────────────────
45
+
46
+ export interface TableColors {
47
+ background: string;
48
+ headerBg: string;
49
+ headerText: string;
50
+ rowBg: string;
51
+ rowAltBg: string;
52
+ rowHoverBg: string;
53
+ selectedBg: string;
54
+ selectedBorder: string;
55
+ border: string;
56
+ divider: string;
57
+ text: string;
58
+ subText: string;
59
+ sortActive: string;
60
+ sortInactive: string;
61
+ checkboxChecked: string;
62
+ emptyText: string;
63
+ }
64
+
65
+ const DEFAULT_COLORS: TableColors = {
66
+ background: palettes.white,
67
+ headerBg: theme.colors.gray[50],
68
+ headerText: theme.colors.gray[400],
69
+ rowBg: palettes.white,
70
+ rowAltBg: palettes.white,
71
+ rowHoverBg: theme.colors.gray[50],
72
+ selectedBg: palettes.indigo[50],
73
+ selectedBorder: palettes.indigo[200],
74
+ border: theme.colors.gray[100],
75
+ divider: theme.colors.gray[100],
76
+ text: theme.colors.gray[900],
77
+ subText: theme.colors.gray[400],
78
+ sortActive: theme.colors.gray[900],
79
+ sortInactive: theme.colors.gray[300],
80
+ checkboxChecked: theme.colors.gray[900],
81
+ emptyText: theme.colors.gray[400],
82
+ };
83
+
84
+ // ─── Props ────────────────────────────────────────────────────────────────────
85
+
86
+ export interface StyledTableProps<T extends TableRow = TableRow> {
87
+ columns: TableColumn<T>[];
88
+ data: T[];
89
+
90
+ // Selection
91
+ selectable?: boolean;
92
+ selectedIds?: (string | number)[];
93
+ onSelectionChange?: (ids: (string | number)[]) => void;
94
+
95
+ // Sorting
96
+ sortKey?: string | null;
97
+ sortDirection?: SortDirection;
98
+ onSort?: (key: string, direction: SortDirection) => void;
99
+
100
+ // Pagination — internal (client-side)
101
+ pagination?: boolean;
102
+ pageSize?: number;
103
+
104
+ // Pagination — external (server-side / Realm / SQLite)
105
+ /**
106
+ * Switch to external pagination mode.
107
+ * The table renders `data` as-is (no internal slicing).
108
+ * Parent is responsible for fetching the correct page.
109
+ */
110
+ externalPagination?: boolean;
111
+ /** Current 0-based page index (controlled) */
112
+ currentPage?: number;
113
+ /** Total number of pages from the datasource */
114
+ totalPages?: number;
115
+ /** Total record count from the datasource */
116
+ totalCount?: number;
117
+ /** Called when the user taps Prev / Next / a page number */
118
+ onPageChange?: (page: number) => void;
119
+ /** Show a loading skeleton over the rows while fetching */
120
+ loading?: boolean;
121
+
122
+ // Performance
123
+ /**
124
+ * Swap the row renderer from Stack.map → FlatList.
125
+ * Use when rendering 30+ unpaginated rows.
126
+ * Automatically true when data.length > 50 and pagination is off.
127
+ * Has no effect in card mode (CardList always uses map).
128
+ */
129
+ virtualized?: boolean;
130
+
131
+ // Display
132
+ striped?: boolean;
133
+ /** Show a horizontal divider between rows */
134
+ showDivider?: boolean;
135
+ /** Horizontal scroll when content overflows */
136
+ scrollable?: boolean;
137
+ /** Empty state content */
138
+ emptyText?: string;
139
+ emptyNode?: CompatNode;
140
+
141
+ // Style
142
+ colors?: Partial<TableColors>;
143
+ borderRadius?: number;
144
+ /** Show outer card border */
145
+ bordered?: boolean;
146
+
147
+ // Responsive card mode
148
+ /**
149
+ * Width threshold below which rows render as cards instead of table rows.
150
+ * Default: 768 (phone portrait).
151
+ * Convenience shorthands: forceTable / forceCards override this entirely.
152
+ */
153
+ cardBreakpoint?: number;
154
+ /** Always render as a table regardless of screen width */
155
+ forceTable?: boolean;
156
+ /** Always render as cards regardless of screen width */
157
+ forceCards?: boolean;
158
+ /**
159
+ * Custom card renderer for each row in card mode.
160
+ * If omitted a default auto-generated card is shown using column definitions.
161
+ */
162
+ cardRender?: (row: T, index: number, selected: boolean, onToggle?: () => void) => CompatNode;
163
+
164
+ // Callbacks
165
+ onRowPress?: (row: T, index: number) => void;
166
+ }
167
+
168
+ // ─── Sort icon ────────────────────────────────────────────────────────────────
169
+
170
+ const SortIcon: React.FC<{
171
+ direction: SortDirection;
172
+ active: boolean;
173
+ color: string;
174
+ inactiveColor: string;
175
+ }> = ({ direction, active, color, inactiveColor }) => (
176
+ <Stack gap={1} marginLeft={4}>
177
+ <StyledText
178
+ fontSize={8}
179
+ lineHeight={9}
180
+ color={active && direction === "asc" ? color : inactiveColor}
181
+ >
182
+
183
+ </StyledText>
184
+ <StyledText
185
+ fontSize={8}
186
+ lineHeight={9}
187
+ color={active && direction === "desc" ? color : inactiveColor}
188
+ >
189
+
190
+ </StyledText>
191
+ </Stack>
192
+ );
193
+
194
+ // ─── Cell wrapper ─────────────────────────────────────────────────────────────
195
+
196
+ const Cell: React.FC<{
197
+ width?: number;
198
+ align?: ColumnAlign;
199
+ children?: CompatNode;
200
+ isFirst?: boolean;
201
+ }> = ({ width, align = "left", children, isFirst }) => (
202
+ <Stack
203
+ flex={width ? undefined : 1}
204
+ width={width}
205
+ alignItems={
206
+ align === "center" ? "center" : align === "right" ? "flex-end" : "flex-start"
207
+ }
208
+ paddingHorizontal={isFirst ? 16 : 12}
209
+ paddingVertical={14}
210
+ justifyContent="center"
211
+ >
212
+ {children}
213
+ </Stack>
214
+ );
215
+
216
+ // ─── StyledTable ──────────────────────────────────────────────────────────────
217
+
218
+
219
+ // ─── Card list (phone layout) ─────────────────────────────────────────────────
220
+
221
+ function CardList<T extends TableRow>({
222
+ columns,
223
+ data,
224
+ selectable,
225
+ selectedIds,
226
+ onSelectionChange,
227
+ cardRender,
228
+ emptyText,
229
+ emptyNode,
230
+ pagination,
231
+ pageSize,
232
+ onRowPress,
233
+ colors: colorOverrides,
234
+ borderRadius,
235
+ }: StyledTableProps<T>) {
236
+ const c = { ...DEFAULT_COLORS, ...colorOverrides };
237
+ const br = borderRadius ?? 16;
238
+
239
+ // internal selection
240
+ const [internalSelected, setInternalSelected] = React.useState<(string | number)[]>([]);
241
+ const selectedIds_ = selectedIds ?? internalSelected;
242
+
243
+ const toggle = (id: string | number) => {
244
+ const next = selectedIds_.includes(id)
245
+ ? selectedIds_.filter((s) => s !== id)
246
+ : [...selectedIds_, id];
247
+ setInternalSelected(next);
248
+ onSelectionChange?.(next);
249
+ };
250
+
251
+ // pagination
252
+ const [page, setPage] = React.useState(0);
253
+ const ps = pageSize ?? 10;
254
+ const totalPages = Math.ceil(data.length / ps);
255
+ const visible = pagination ? data.slice(page * ps, (page + 1) * ps) : data;
256
+
257
+ if (visible.length === 0) {
258
+ return (
259
+ <Stack paddingVertical={48} alignItems="center" justifyContent="center">
260
+ {emptyNode ?? (
261
+ <StyledText fontSize={14} color={c.emptyText}>{emptyText ?? "No data"}</StyledText>
262
+ )}
263
+ </Stack>
264
+ );
265
+ }
266
+
267
+ return (
268
+ <Stack gap={10}>
269
+ {visible.map((row, idx) => {
270
+ const isSelected = selectedIds_.includes(row.id);
271
+
272
+ if (cardRender) {
273
+ return (
274
+ <React.Fragment key={row.id}>
275
+ {cardRender(row, idx, isSelected, selectable ? () => toggle(row.id) : undefined)}
276
+ </React.Fragment>
277
+ );
278
+ }
279
+
280
+ // ── Default auto-card ─────────────────────────────────────────────
281
+ // Uses column definitions to render label: value pairs.
282
+ const [primary, ...rest] = columns;
283
+
284
+ return (
285
+ <StyledPressable
286
+ key={row.id}
287
+ onPress={() => onRowPress?.(row, idx)}
288
+ disabled={!onRowPress && !selectable}
289
+ borderRadius={br}
290
+ borderWidth={1}
291
+ borderColor={isSelected ? c.selectedBorder : c.border}
292
+ backgroundColor={isSelected ? c.selectedBg : c.rowBg}
293
+ borderLeftWidth={isSelected ? 3 : 1}
294
+ borderLeftColor={isSelected ? c.selectedBorder : c.border}
295
+ overflow="hidden"
296
+ style={{
297
+ shadowColor: "#000",
298
+ shadowOffset: { width: 0, height: 1 },
299
+ shadowOpacity: 0.05,
300
+ shadowRadius: 4,
301
+ elevation: 1,
302
+ }}
303
+ >
304
+ {/* Card header — primary column + checkbox */}
305
+ <Stack
306
+ horizontal
307
+ alignItems="center"
308
+ justifyContent="space-between"
309
+ paddingHorizontal={14}
310
+ paddingVertical={12}
311
+ borderBottomWidth={1}
312
+ borderBottomColor={c.divider}
313
+ backgroundColor={c.headerBg}
314
+ >
315
+ <Stack flex={1}>
316
+ {primary.render
317
+ ? primary.render(row[primary.key], row, idx)
318
+ : (
319
+ <StyledText fontSize={14} fontWeight="700" color={c.text} numberOfLines={1}>
320
+ {row[primary.key]}
321
+ </StyledText>
322
+ )
323
+ }
324
+ </Stack>
325
+ {selectable && (
326
+ <StyledCheckBox
327
+ checked={isSelected}
328
+ onCheck={() => toggle(row.id)}
329
+ size={18}
330
+ checkedColor={c.checkboxChecked}
331
+ />
332
+ )}
333
+ </Stack>
334
+
335
+ {/* Card body — remaining columns as label/value pairs */}
336
+ <Stack paddingHorizontal={14} paddingVertical={10} gap={8}>
337
+ {rest.map((col) => (
338
+ <Stack key={col.key} horizontal alignItems="center" justifyContent="space-between" gap={8}>
339
+ <StyledText fontSize={12} color={c.subText} fontWeight="500">
340
+ {col.title}
341
+ </StyledText>
342
+ <Stack alignItems="flex-end">
343
+ {col.render
344
+ ? col.render(row[col.key], row, idx)
345
+ : (
346
+ <StyledText fontSize={13} color={c.text}>
347
+ {row[col.key] ?? "—"}
348
+ </StyledText>
349
+ )
350
+ }
351
+ </Stack>
352
+ </Stack>
353
+ ))}
354
+ </Stack>
355
+ </StyledPressable>
356
+ );
357
+ })}
358
+
359
+ {/* Pagination */}
360
+ {pagination && totalPages > 1 && (
361
+ <Stack horizontal alignItems="center" justifyContent="space-between" paddingVertical={4}>
362
+ <StyledText fontSize={12} color={c.subText}>
363
+ {page * ps + 1}–{Math.min((page + 1) * ps, data.length)} of {data.length}
364
+ </StyledText>
365
+ <Stack horizontal gap={6}>
366
+ <StyledPressable
367
+ onPress={() => setPage((p) => Math.max(0, p - 1))}
368
+ disabled={page === 0}
369
+ paddingHorizontal={12} paddingVertical={6}
370
+ borderRadius={8} borderWidth={1}
371
+ borderColor={page === 0 ? c.border : c.sortActive}
372
+ opacity={page === 0 ? 0.4 : 1}
373
+ >
374
+ <StyledText fontSize={12} fontWeight="600" color={c.text}>← Prev</StyledText>
375
+ </StyledPressable>
376
+ <StyledPressable
377
+ onPress={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
378
+ disabled={page >= totalPages - 1}
379
+ paddingHorizontal={12} paddingVertical={6}
380
+ borderRadius={8} borderWidth={1}
381
+ borderColor={page >= totalPages - 1 ? c.border : c.sortActive}
382
+ opacity={page >= totalPages - 1 ? 0.4 : 1}
383
+ >
384
+ <StyledText fontSize={12} fontWeight="600" color={c.text}>Next →</StyledText>
385
+ </StyledPressable>
386
+ </Stack>
387
+ </Stack>
388
+ )}
389
+ </Stack>
390
+ );
391
+ }
392
+
393
+ export function StyledTable<T extends TableRow = TableRow>({
394
+ columns,
395
+ data,
396
+
397
+ selectable = false,
398
+ selectedIds: controlledSelectedIds,
399
+ onSelectionChange,
400
+
401
+ sortKey: controlledSortKey,
402
+ sortDirection: controlledSortDir,
403
+ onSort,
404
+
405
+ pagination = false,
406
+ pageSize = 10,
407
+
408
+ striped = false,
409
+ showDivider = true,
410
+ scrollable = false,
411
+ emptyText = "No data",
412
+ emptyNode,
413
+
414
+ colors: colorOverrides,
415
+ borderRadius = 16,
416
+ bordered = true,
417
+ virtualized,
418
+ cardBreakpoint = 768,
419
+ forceTable,
420
+ forceCards,
421
+ cardRender,
422
+
423
+ externalPagination = false,
424
+ currentPage = 0,
425
+ totalPages: externalTotalPages,
426
+ totalCount,
427
+ onPageChange,
428
+ loading = false,
429
+
430
+ onRowPress,
431
+ }: StyledTableProps<T>) {
432
+ const { width } = useWindowDimensions();
433
+ const isCardMode = forceCards ? true : forceTable ? false : (cardBreakpoint > 0 && width < cardBreakpoint);
434
+
435
+ // ── Card mode — render rows as stacked cards ─────────────────────────────
436
+ if (isCardMode) {
437
+ return (
438
+ <CardList
439
+ columns={columns}
440
+ data={data}
441
+ selectable={selectable}
442
+ selectedIds={controlledSelectedIds}
443
+ onSelectionChange={onSelectionChange}
444
+ cardRender={cardRender}
445
+ emptyText={emptyText}
446
+ emptyNode={emptyNode}
447
+ pagination={pagination}
448
+ pageSize={pageSize}
449
+ onRowPress={onRowPress}
450
+ colors={colorOverrides}
451
+ borderRadius={borderRadius}
452
+ />
453
+ );
454
+ }
455
+
456
+ const c = { ...DEFAULT_COLORS, ...colorOverrides };
457
+
458
+ // ── Internal selection state (uncontrolled fallback) ───────────────────
459
+ const [internalSelected, setInternalSelected] = useState<(string | number)[]>([]);
460
+ const selectedIds = controlledSelectedIds ?? internalSelected;
461
+
462
+ const toggleRow = useCallback((id: string | number) => {
463
+ const next = selectedIds.includes(id)
464
+ ? selectedIds.filter((s) => s !== id)
465
+ : [...selectedIds, id];
466
+ setInternalSelected(next);
467
+ onSelectionChange?.(next);
468
+ }, [selectedIds, onSelectionChange]);
469
+
470
+ const toggleAll = useCallback(() => {
471
+ const allIds = data.map((r) => r.id);
472
+ const allSelected = allIds.every((id) => selectedIds.includes(id));
473
+ const next = allSelected ? [] : allIds;
474
+ setInternalSelected(next);
475
+ onSelectionChange?.(next);
476
+ }, [data, selectedIds, onSelectionChange]);
477
+
478
+ const allSelected = data.length > 0 && data.every((r) => selectedIds.includes(r.id));
479
+
480
+ // ── Internal sort state (uncontrolled fallback) ────────────────────────
481
+ const [internalSortKey, setInternalSortKey] = useState<string | null>(null);
482
+ const [internalSortDir, setInternalSortDir] = useState<SortDirection>(null);
483
+ const activeSortKey = controlledSortKey ?? internalSortKey;
484
+ const activeSortDir = controlledSortDir ?? internalSortDir;
485
+
486
+ const handleSort = useCallback((key: string) => {
487
+ const next: SortDirection =
488
+ activeSortKey === key
489
+ ? activeSortDir === "asc" ? "desc" : activeSortDir === "desc" ? null : "asc"
490
+ : "asc";
491
+ setInternalSortKey(next === null ? null : key);
492
+ setInternalSortDir(next);
493
+ onSort?.(key, next);
494
+ }, [activeSortKey, activeSortDir, onSort]);
495
+
496
+ // ── Client-side sort (used when onSort not provided) ───────────────────
497
+ const sortedData = React.useMemo(() => {
498
+ if (!activeSortKey || !activeSortDir || onSort) return data;
499
+ return [...data].sort((a, b) => {
500
+ const av = a[activeSortKey];
501
+ const bv = b[activeSortKey];
502
+ const cmp = typeof av === "number" && typeof bv === "number"
503
+ ? av - bv
504
+ : String(av ?? "").localeCompare(String(bv ?? ""));
505
+ return activeSortDir === "asc" ? cmp : -cmp;
506
+ });
507
+ }, [data, activeSortKey, activeSortDir, onSort]);
508
+
509
+ // ── Pagination ────────────────────────────────────────────────────────
510
+ const [internalPage, setInternalPage] = useState(0);
511
+
512
+ // External mode: parent controls page + data
513
+ // Internal mode: we slice sortedData ourselves
514
+ const page = externalPagination ? currentPage : internalPage;
515
+ const totalPages = externalPagination
516
+ ? (externalTotalPages ?? 1)
517
+ : Math.ceil(sortedData.length / pageSize);
518
+ const visibleData = externalPagination
519
+ ? sortedData // parent already gave us just this page
520
+ : pagination
521
+ ? sortedData.slice(page * pageSize, (page + 1) * pageSize)
522
+ : sortedData;
523
+
524
+ // Auto-enable FlatList virtualisation for large unpaginated datasets
525
+ const useVirtualized = virtualized ?? (!pagination && !externalPagination && visibleData.length > 50);
526
+
527
+ const handlePageChange = (p: number) => {
528
+ if (externalPagination) {
529
+ onPageChange?.(p);
530
+ } else {
531
+ setInternalPage(p);
532
+ }
533
+ };
534
+
535
+ // Row count label
536
+ const rowStart = page * pageSize + 1;
537
+ const rowEnd = externalPagination
538
+ ? (totalCount != null ? Math.min(rowStart + visibleData.length - 1, totalCount) : rowStart + visibleData.length - 1)
539
+ : Math.min((page + 1) * pageSize, sortedData.length);
540
+ const rowTotal = externalPagination ? (totalCount ?? "?") : sortedData.length;
541
+
542
+ // ── Render ────────────────────────────────────────────────────────────
543
+ const tableContent = (
544
+ <Stack>
545
+ {/* Header row */}
546
+ <Stack
547
+ horizontal
548
+ backgroundColor={c.headerBg}
549
+ borderTopLeftRadius={borderRadius}
550
+ borderTopRightRadius={borderRadius}
551
+ borderBottomWidth={1}
552
+ borderBottomColor={c.border}
553
+ >
554
+ {selectable && (
555
+ <Stack
556
+ width={52}
557
+ paddingHorizontal={16}
558
+ paddingVertical={14}
559
+ alignItems="center"
560
+ justifyContent="center"
561
+ >
562
+ <StyledCheckBox
563
+ checked={allSelected}
564
+ onCheck={toggleAll}
565
+ size={18}
566
+ checkedColor={c.checkboxChecked}
567
+ />
568
+ </Stack>
569
+ )}
570
+
571
+ {columns.map((col, i) => (
572
+ <Cell key={col.key} width={col.width} align={col.align} isFirst={i === 0 && !selectable}>
573
+ <StyledPressable
574
+ flexDirection="row"
575
+ alignItems="center"
576
+ onPress={col.sortable ? () => handleSort(col.key) : undefined}
577
+ disabled={!col.sortable}
578
+ >
579
+ <StyledText
580
+ fontSize={12}
581
+ fontWeight="600"
582
+ color={c.headerText}
583
+ letterSpacing={0.3}
584
+ >
585
+ {col.title}
586
+ </StyledText>
587
+ {col.sortable && (
588
+ <SortIcon
589
+ direction={activeSortDir}
590
+ active={activeSortKey === col.key}
591
+ color={c.sortActive}
592
+ inactiveColor={c.sortInactive}
593
+ />
594
+ )}
595
+ </StyledPressable>
596
+ </Cell>
597
+ ))}
598
+ </Stack>
599
+
600
+ {/* Data rows */}
601
+ {visibleData.length === 0 ? (
602
+ <Stack
603
+ paddingVertical={48}
604
+ alignItems="center"
605
+ justifyContent="center"
606
+ backgroundColor={c.rowBg}
607
+ borderBottomLeftRadius={borderRadius}
608
+ borderBottomRightRadius={borderRadius}
609
+ >
610
+ {emptyNode ?? (
611
+ <StyledText fontSize={14} color={c.emptyText}>{emptyText}</StyledText>
612
+ )}
613
+ </Stack>
614
+ ) : useVirtualized ? (
615
+ // ── FlatList path — only mounts rows in the viewport ────────────
616
+ <FlatList
617
+ data={visibleData}
618
+ keyExtractor={(row) => String(row.id)}
619
+ // Pass selectedIds + striped as extraData so FlatList knows to re-render
620
+ // when selection changes (FlatList only re-renders items whose key changes
621
+ // otherwise — extraData forces a full diff)
622
+ extraData={[selectedIds, striped]}
623
+ scrollEnabled={false} // outer ScrollView handles scrolling
624
+ renderItem={({ item: row, index: rowIndex }) => {
625
+ const isSelected = selectedIds.includes(row.id);
626
+ const isLast = rowIndex === visibleData.length - 1;
627
+ const isAlt = striped && rowIndex % 2 !== 0;
628
+ const rowBg = isSelected ? c.selectedBg : isAlt ? c.rowAltBg : c.rowBg;
629
+ return (
630
+ <StyledPressable
631
+ flexDirection="row"
632
+ alignItems="center"
633
+ backgroundColor={rowBg}
634
+ borderLeftWidth={isSelected ? 3 : 0}
635
+ borderLeftColor={isSelected ? c.selectedBorder : "transparent"}
636
+ borderBottomWidth={showDivider && !isLast ? 1 : 0}
637
+ borderBottomColor={c.divider}
638
+ borderBottomLeftRadius={isLast ? borderRadius : 0}
639
+ borderBottomRightRadius={isLast ? borderRadius : 0}
640
+ onPress={() => onRowPress?.(row, rowIndex)}
641
+ disabled={!onRowPress && !selectable}
642
+ >
643
+ {selectable && (
644
+ <Stack width={52} paddingHorizontal={16} paddingVertical={14}
645
+ alignItems="center" justifyContent="center">
646
+ <StyledCheckBox checked={isSelected} onCheck={() => toggleRow(row.id)}
647
+ size={18} checkedColor={c.checkboxChecked} />
648
+ </Stack>
649
+ )}
650
+ {columns.map((col, colIndex) => (
651
+ <Cell key={col.key} width={col.width} align={col.align}
652
+ isFirst={colIndex === 0 && !selectable}>
653
+ {col.render
654
+ ? col.render(row[col.key], row, rowIndex)
655
+ : <StyledText fontSize={14} color={c.text} numberOfLines={1}>{row[col.key] ?? "—"}</StyledText>
656
+ }
657
+ </Cell>
658
+ ))}
659
+ </StyledPressable>
660
+ );
661
+ }}
662
+ />
663
+ ) : (
664
+ // ── Stack.map path — simple, best for ≤ 50 rows ─────────────────
665
+ visibleData.map((row, rowIndex) => {
666
+ const isSelected = selectedIds.includes(row.id);
667
+ const isLast = rowIndex === visibleData.length - 1;
668
+ const isAlt = striped && rowIndex % 2 !== 0;
669
+
670
+ const rowBg = isSelected
671
+ ? c.selectedBg
672
+ : isAlt
673
+ ? c.rowAltBg
674
+ : c.rowBg;
675
+
676
+ return (
677
+ <StyledPressable
678
+ key={row.id}
679
+ flexDirection="row"
680
+ alignItems="center"
681
+ backgroundColor={rowBg}
682
+ borderLeftWidth={isSelected ? 3 : 0}
683
+ borderLeftColor={isSelected ? c.selectedBorder : "transparent"}
684
+ borderBottomWidth={showDivider && !isLast ? 1 : 0}
685
+ borderBottomColor={c.divider}
686
+ borderBottomLeftRadius={isLast ? borderRadius : 0}
687
+ borderBottomRightRadius={isLast ? borderRadius : 0}
688
+ onPress={() => onRowPress?.(row, rowIndex)}
689
+ disabled={!onRowPress && !selectable}
690
+ >
691
+ {selectable && (
692
+ <Stack
693
+ width={52}
694
+ paddingHorizontal={16}
695
+ paddingVertical={14}
696
+ alignItems="center"
697
+ justifyContent="center"
698
+ >
699
+ <StyledCheckBox
700
+ checked={isSelected}
701
+ onCheck={() => toggleRow(row.id)}
702
+ size={18}
703
+ checkedColor={c.checkboxChecked}
704
+ />
705
+ </Stack>
706
+ )}
707
+
708
+ {columns.map((col, colIndex) => (
709
+ <Cell
710
+ key={col.key}
711
+ width={col.width}
712
+ align={col.align}
713
+ isFirst={colIndex === 0 && !selectable}
714
+ >
715
+ {col.render
716
+ ? col.render(row[col.key], row, rowIndex)
717
+ : (
718
+ <StyledText
719
+ fontSize={14}
720
+ color={c.text}
721
+ numberOfLines={1}
722
+ >
723
+ {row[col.key] ?? "—"}
724
+ </StyledText>
725
+ )
726
+ }
727
+ </Cell>
728
+ ))}
729
+ </StyledPressable>
730
+ );
731
+ })
732
+ )}
733
+
734
+ {/* Pagination footer */}
735
+ {(pagination || externalPagination) && totalPages > 1 && (
736
+ <Stack
737
+ horizontal
738
+ alignItems="center"
739
+ justifyContent="space-between"
740
+ paddingHorizontal={16}
741
+ paddingVertical={12}
742
+ borderTopWidth={1}
743
+ borderTopColor={c.border}
744
+ backgroundColor={c.headerBg}
745
+ borderBottomLeftRadius={borderRadius}
746
+ borderBottomRightRadius={borderRadius}
747
+ >
748
+ <StyledText fontSize={12} color={c.subText}>
749
+ {rowStart}–{rowEnd} of {rowTotal}
750
+ </StyledText>
751
+
752
+ <Stack horizontal gap={6}>
753
+ {/* Page number pills — show up to 5 around current page */}
754
+ {Array.from({ length: totalPages }, (_, i) => i)
755
+ .filter(i => i === 0 || i === totalPages - 1 || Math.abs(i - page) <= 1)
756
+ .reduce<(number | "…")[]>((acc, i, idx, arr) => {
757
+ if (idx > 0 && (i as number) - (arr[idx - 1] as number) > 1) acc.push("…");
758
+ acc.push(i);
759
+ return acc;
760
+ }, [])
761
+ .map((item, idx) =>
762
+ item === "…" ? (
763
+ <StyledText key={`ellipsis-${idx}`} fontSize={12} color={c.subText} paddingHorizontal={4}>…</StyledText>
764
+ ) : (
765
+ <StyledPressable
766
+ key={item}
767
+ onPress={() => handlePageChange(item as number)}
768
+ disabled={loading}
769
+ paddingHorizontal={10}
770
+ paddingVertical={6}
771
+ borderRadius={8}
772
+ borderWidth={1}
773
+ borderColor={page === item ? c.sortActive : c.border}
774
+ backgroundColor={page === item ? c.sortActive : "transparent"}
775
+ >
776
+ <StyledText
777
+ fontSize={12}
778
+ fontWeight="600"
779
+ color={page === item ? c.background : c.text}
780
+ >
781
+ {(item as number) + 1}
782
+ </StyledText>
783
+ </StyledPressable>
784
+ )
785
+ )
786
+ }
787
+ <StyledPressable
788
+ onPress={() => handlePageChange(Math.min(totalPages - 1, page + 1))}
789
+ disabled={page >= totalPages - 1 || loading}
790
+ paddingHorizontal={12}
791
+ paddingVertical={6}
792
+ borderRadius={8}
793
+ borderWidth={1}
794
+ borderColor={page >= totalPages - 1 ? c.border : c.sortActive}
795
+ opacity={page >= totalPages - 1 ? 0.4 : 1}
796
+ >
797
+ <StyledText fontSize={12} fontWeight="600" color={c.text}>→</StyledText>
798
+ </StyledPressable>
799
+ </Stack>
800
+ </Stack>
801
+ )}
802
+
803
+ {/* Loading overlay */}
804
+ {loading && (
805
+ <Stack
806
+ position="absolute"
807
+ top={0} left={0} right={0} bottom={0}
808
+ backgroundColor="rgba(255,255,255,0.7)"
809
+ alignItems="center"
810
+ justifyContent="center"
811
+ borderRadius={borderRadius}
812
+ >
813
+ <StyledText fontSize={13} color={c.subText}>Loading…</StyledText>
814
+ </Stack>
815
+ )}
816
+ </Stack>
817
+ );
818
+
819
+ return (
820
+ <Stack
821
+ borderRadius={borderRadius}
822
+ borderWidth={bordered ? 1 : 0}
823
+ borderColor={c.border}
824
+ overflow="hidden"
825
+ backgroundColor={c.background}
826
+ style={bordered ? {
827
+ shadowColor: "#000",
828
+ shadowOffset: { width: 0, height: 1 },
829
+ shadowOpacity: 0.06,
830
+ shadowRadius: 8,
831
+ elevation: 2,
832
+ } : undefined}
833
+ >
834
+ {scrollable ? (
835
+ <ScrollView horizontal showsHorizontalScrollIndicator={false}>
836
+ {tableContent}
837
+ </ScrollView>
838
+ ) : tableContent}
839
+ </Stack>
840
+ );
841
+ }
842
+
843
+ export default StyledTable;