fluent-styles 1.61.0 → 1.62.1
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/README.md +592 -33
- package/lib/commonjs/index.js +24 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/tabBar/TabBar.js.map +1 -1
- package/lib/commonjs/table/index.js +650 -0
- package/lib/commonjs/table/index.js.map +1 -0
- package/lib/commonjs/table/usepaginatedquery.js +181 -0
- package/lib/commonjs/table/usepaginatedquery.js.map +1 -0
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/tabBar/TabBar.js.map +1 -1
- package/lib/module/table/index.js +644 -0
- package/lib/module/table/index.js.map +1 -0
- package/lib/module/table/usepaginatedquery.js +178 -0
- package/lib/module/table/usepaginatedquery.js.map +1 -0
- package/lib/typescript/header/index.d.ts +1 -3
- package/lib/typescript/header/index.d.ts.map +1 -1
- package/lib/typescript/icons/backArrow.d.ts +1 -1
- package/lib/typescript/icons/bellFill.d.ts +1 -1
- package/lib/typescript/icons/bellOutline.d.ts +1 -1
- package/lib/typescript/icons/checkmark.d.ts +1 -1
- package/lib/typescript/icons/delete.d.ts +1 -1
- package/lib/typescript/icons/downChevron.d.ts +1 -1
- package/lib/typescript/icons/error.d.ts +1 -1
- package/lib/typescript/icons/forwardArrow.d.ts +1 -1
- package/lib/typescript/icons/info.d.ts +1 -1
- package/lib/typescript/icons/leftChevron.d.ts +1 -1
- package/lib/typescript/icons/rightChevron.d.ts +1 -1
- package/lib/typescript/icons/save.d.ts +1 -1
- package/lib/typescript/icons/success.d.ts +1 -1
- package/lib/typescript/icons/upChevron.d.ts +1 -1
- package/lib/typescript/icons/warning.d.ts +1 -1
- package/lib/typescript/index.d.ts +2 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/tabBar/TabBar.d.ts +2 -3
- package/lib/typescript/tabBar/TabBar.d.ts.map +1 -1
- package/lib/typescript/table/index.d.ts +107 -0
- package/lib/typescript/table/index.d.ts.map +1 -0
- package/lib/typescript/table/usepaginatedquery.d.ts +114 -0
- package/lib/typescript/table/usepaginatedquery.d.ts.map +1 -0
- package/lib/typescript/utiles/createIcon.d.ts +1 -1
- package/package.json +31 -41
- package/src/index.ts +2 -0
- package/src/tabBar/TabBar.tsx +1 -1
- package/src/table/index.tsx +843 -0
- package/src/table/usepaginatedquery.tsx +275 -0
|
@@ -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;
|