@lotics/ui 1.22.0 → 1.24.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.
- package/package.json +5 -4
- package/src/{grid/column_filter.tsx → column_filter.tsx} +41 -57
- package/src/file_thumbnail_grid.tsx +165 -0
- package/src/image_gallery.tsx +123 -0
- package/src/table.tsx +9 -1
- package/src/table.web.tsx +18 -11
- package/src/{grid/data_grid_picker.tsx → table_picker.tsx} +96 -118
- package/src/table_types.ts +5 -0
- package/src/grid/sortable_header_cell.tsx +0 -58
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
},
|
|
17
17
|
"./file_preview_types": "./src/file_preview_types.ts",
|
|
18
18
|
"./file_gallery_modal": "./src/file_gallery_modal.tsx",
|
|
19
|
+
"./image_gallery": "./src/image_gallery.tsx",
|
|
20
|
+
"./file_thumbnail_grid": "./src/file_thumbnail_grid.tsx",
|
|
19
21
|
"./pagination": "./src/pagination.tsx",
|
|
20
22
|
"./bar_chart": "./src/bar_chart.tsx",
|
|
21
23
|
"./line_chart": "./src/line_chart.tsx",
|
|
@@ -151,9 +153,8 @@
|
|
|
151
153
|
"./grid/data_grid": "./src/grid/data_grid.tsx",
|
|
152
154
|
"./grid/data_grid_context": "./src/grid/data_grid_context.ts",
|
|
153
155
|
"./grid/search_highlight": "./src/grid/search_highlight.ts",
|
|
154
|
-
"./
|
|
155
|
-
"./
|
|
156
|
-
"./grid/data_grid_picker": "./src/grid/data_grid_picker.tsx"
|
|
156
|
+
"./column_filter": "./src/column_filter.tsx",
|
|
157
|
+
"./table_picker": "./src/table_picker.tsx"
|
|
157
158
|
},
|
|
158
159
|
"files": [
|
|
159
160
|
"src"
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { Popover, PopoverTrigger, PopoverContent } from "
|
|
10
|
-
import type { PickerOption } from "
|
|
1
|
+
import { View, StyleSheet } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { Icon } from "./icon";
|
|
4
|
+
import { colors } from "./colors";
|
|
5
|
+
import { TextInputField } from "./text_input_field";
|
|
6
|
+
import { NumberInput } from "./number_input";
|
|
7
|
+
import { PickerMenu } from "./picker_menu";
|
|
8
|
+
import { PillButton } from "./pill_button";
|
|
9
|
+
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
10
|
+
import type { PickerOption } from "./picker";
|
|
11
11
|
|
|
12
12
|
/** A column the picker can filter on. `type` selects the control + operators. */
|
|
13
13
|
export interface FilterableColumn {
|
|
@@ -72,18 +72,39 @@ export function isColumnFilterActive(value: ColumnFilterValue | undefined): bool
|
|
|
72
72
|
return value.selected.length > 0;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** A short human summary of an active value, for the pill label ("Vàng, Đỏ", "≥ 10"). */
|
|
76
|
+
export function columnFilterSummary(
|
|
77
|
+
column: FilterableColumn,
|
|
78
|
+
value: ColumnFilterValue | undefined,
|
|
79
|
+
): string {
|
|
80
|
+
if (!value) return "";
|
|
81
|
+
if (value.kind === "text") return value.query.trim();
|
|
82
|
+
if (value.kind === "number") {
|
|
83
|
+
const { min, max } = value;
|
|
84
|
+
if (min != null && max != null) return `${min}–${max}`;
|
|
85
|
+
if (min != null) return `≥ ${min}`;
|
|
86
|
+
if (max != null) return `≤ ${max}`;
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
return value.selected
|
|
90
|
+
.map((k) => column.options?.find((o) => o.value === k)?.label ?? k)
|
|
91
|
+
.join(", ");
|
|
92
|
+
}
|
|
93
|
+
|
|
75
94
|
export interface ColumnFilterProps {
|
|
76
95
|
column: FilterableColumn;
|
|
77
96
|
value: ColumnFilterValue | undefined;
|
|
78
97
|
onChange: (value: ColumnFilterValue | undefined) => void;
|
|
79
|
-
/** Accessible name for the clear control. Pass a translated string. Default "Clear". */
|
|
98
|
+
/** Accessible name for the clear (X) control. Pass a translated string. Default "Clear". */
|
|
80
99
|
clearLabel?: string;
|
|
81
100
|
}
|
|
82
101
|
|
|
83
102
|
/**
|
|
84
|
-
* A
|
|
85
|
-
*
|
|
86
|
-
*
|
|
103
|
+
* A toolbar-style filter pill: the same `PillButton` the table toolbar uses, so
|
|
104
|
+
* a picker's filters read identically. Inactive → "Label ⌄"; active →
|
|
105
|
+
* "Label: summary" with an X to clear. Pressing the pill opens a type-aware
|
|
106
|
+
* editor (text contains / number range / multi-select). Controlled — the
|
|
107
|
+
* consumer holds the `ColumnFilterValue` and maps it to query conditions via
|
|
87
108
|
* `columnFilterToConditions`. Pure UI; no data layer.
|
|
88
109
|
*/
|
|
89
110
|
export function ColumnFilter(props: ColumnFilterProps) {
|
|
@@ -93,17 +114,15 @@ export function ColumnFilter(props: ColumnFilterProps) {
|
|
|
93
114
|
return (
|
|
94
115
|
<Popover side="bottom" align="start">
|
|
95
116
|
<PopoverTrigger>
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
style={[styles.pill, active && styles.pillActive]}
|
|
117
|
+
<PillButton
|
|
118
|
+
onDismiss={active ? () => onChange(undefined) : undefined}
|
|
119
|
+
dismissTooltip={clearLabel}
|
|
100
120
|
>
|
|
101
|
-
<Icon name="list-filter" size={13} color={active ? colors.zinc["950"] : colors.zinc["500"]} />
|
|
102
121
|
<Text size="sm" weight="medium" color={active ? "zinc-900" : "zinc-700"} numberOfLines={1}>
|
|
103
|
-
{column.label}
|
|
122
|
+
{active ? `${column.label}: ${columnFilterSummary(column, value)}` : column.label}
|
|
104
123
|
</Text>
|
|
105
|
-
<Icon name="chevron-down" size={14} color={colors.zinc["400"]} />
|
|
106
|
-
</
|
|
124
|
+
{!active ? <Icon name="chevron-down" size={14} color={colors.zinc["400"]} /> : null}
|
|
125
|
+
</PillButton>
|
|
107
126
|
</PopoverTrigger>
|
|
108
127
|
<PopoverContent style={styles.content}>
|
|
109
128
|
{column.type === "text" ? (
|
|
@@ -142,40 +161,12 @@ export function ColumnFilter(props: ColumnFilterProps) {
|
|
|
142
161
|
onValueChange={(selected) => onChange({ kind: "select", selected })}
|
|
143
162
|
/>
|
|
144
163
|
)}
|
|
145
|
-
{active ? (
|
|
146
|
-
<PressableHighlight
|
|
147
|
-
accessibilityRole="button"
|
|
148
|
-
accessibilityLabel={clearLabel}
|
|
149
|
-
onPress={() => onChange(undefined)}
|
|
150
|
-
style={styles.clear}
|
|
151
|
-
>
|
|
152
|
-
<Icon name="x" size={14} color={colors.zinc["500"]} />
|
|
153
|
-
<Text size="sm" color="zinc-500">
|
|
154
|
-
{clearLabel}
|
|
155
|
-
</Text>
|
|
156
|
-
</PressableHighlight>
|
|
157
|
-
) : null}
|
|
158
164
|
</PopoverContent>
|
|
159
165
|
</Popover>
|
|
160
166
|
);
|
|
161
167
|
}
|
|
162
168
|
|
|
163
169
|
const styles = StyleSheet.create({
|
|
164
|
-
pill: {
|
|
165
|
-
flexDirection: "row",
|
|
166
|
-
alignItems: "center",
|
|
167
|
-
gap: 5,
|
|
168
|
-
height: 32,
|
|
169
|
-
paddingHorizontal: 10,
|
|
170
|
-
borderRadius: 8,
|
|
171
|
-
borderWidth: 1,
|
|
172
|
-
borderColor: colors.border,
|
|
173
|
-
backgroundColor: colors.background,
|
|
174
|
-
},
|
|
175
|
-
pillActive: {
|
|
176
|
-
borderColor: colors.zinc["400"],
|
|
177
|
-
backgroundColor: colors.zinc["100"],
|
|
178
|
-
},
|
|
179
170
|
content: {
|
|
180
171
|
minWidth: 240,
|
|
181
172
|
gap: 8,
|
|
@@ -185,11 +176,4 @@ const styles = StyleSheet.create({
|
|
|
185
176
|
alignItems: "center",
|
|
186
177
|
gap: 8,
|
|
187
178
|
},
|
|
188
|
-
clear: {
|
|
189
|
-
flexDirection: "row",
|
|
190
|
-
alignItems: "center",
|
|
191
|
-
gap: 6,
|
|
192
|
-
paddingVertical: 6,
|
|
193
|
-
paddingHorizontal: 4,
|
|
194
|
-
},
|
|
195
179
|
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Pure, reusable thumbnail-grid layout + a file-specific specialization.
|
|
2
|
+
//
|
|
3
|
+
// `ThumbnailGrid<T>` is the layout engine (three mutually-exclusive modes:
|
|
4
|
+
// compact / fixed-columns / auto-columns, with `maxVisible` overflow). It is
|
|
5
|
+
// item-agnostic — pass `renderItem`. `FileThumbnailGrid` is the common case:
|
|
6
|
+
// a grid of `DisplayFile`s rendered with `FileThumbnail` (any file type —
|
|
7
|
+
// image/video/audio/doc — via FileThumbnail's own type handling).
|
|
8
|
+
//
|
|
9
|
+
// The Lotics-coupled file grid (`@lotics/ui-internal/file_grid`, which mixes in
|
|
10
|
+
// upload-progress thumbnails) composes `ThumbnailGrid` with its own renderItem,
|
|
11
|
+
// so the layout lives in exactly one place.
|
|
12
|
+
import React, { useState } from "react";
|
|
13
|
+
import { View, StyleSheet, type ViewStyle } from "react-native";
|
|
14
|
+
import { Text } from "./text";
|
|
15
|
+
import { FileThumbnail, type DisplayFile, THUMBNAIL_SIZE } from "./file_thumbnail";
|
|
16
|
+
|
|
17
|
+
export interface ThumbnailGridProps<T> {
|
|
18
|
+
items: T[];
|
|
19
|
+
keyExtractor: (item: T) => string;
|
|
20
|
+
/** Render one item. `size` is passed in compact mode (fixed px); omitted in
|
|
21
|
+
* column modes, where the item fills its flex cell. */
|
|
22
|
+
renderItem: (item: T, size?: number) => React.ReactNode;
|
|
23
|
+
/** Fixed pixel size per item → flat horizontal wrap. Takes precedence. */
|
|
24
|
+
itemSize?: number;
|
|
25
|
+
/** Fixed column count → items fill 1/N of the row width. */
|
|
26
|
+
columns?: number;
|
|
27
|
+
/** Minimum item width for auto column count when neither itemSize nor columns
|
|
28
|
+
* is set. Default 96. */
|
|
29
|
+
minItemWidth?: number;
|
|
30
|
+
/** Gap between items. Default 8. */
|
|
31
|
+
gap?: number;
|
|
32
|
+
/** Max items shown before a "+N" overflow indicator. */
|
|
33
|
+
maxVisible?: number;
|
|
34
|
+
/** Alignment of a partial last row. Default "start". */
|
|
35
|
+
partialRowAlign?: "start" | "end";
|
|
36
|
+
style?: ViewStyle;
|
|
37
|
+
/** Disable press/click interactions (sets pointerEvents none). */
|
|
38
|
+
disablePress?: boolean;
|
|
39
|
+
/** Rendered inside the container after the grid (e.g. a retry-all button). */
|
|
40
|
+
footer?: React.ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ThumbnailGrid<T>(props: ThumbnailGridProps<T>) {
|
|
44
|
+
const {
|
|
45
|
+
items,
|
|
46
|
+
keyExtractor,
|
|
47
|
+
renderItem,
|
|
48
|
+
itemSize,
|
|
49
|
+
columns,
|
|
50
|
+
minItemWidth = THUMBNAIL_SIZE,
|
|
51
|
+
gap = 8,
|
|
52
|
+
maxVisible,
|
|
53
|
+
partialRowAlign = "start",
|
|
54
|
+
style,
|
|
55
|
+
disablePress,
|
|
56
|
+
footer,
|
|
57
|
+
} = props;
|
|
58
|
+
|
|
59
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
60
|
+
|
|
61
|
+
const isCompact = itemSize !== undefined;
|
|
62
|
+
const dynamicColumns =
|
|
63
|
+
!isCompact && columns === undefined && containerWidth > 0
|
|
64
|
+
? Math.max(1, Math.floor((containerWidth + gap) / (minItemWidth + gap)))
|
|
65
|
+
: undefined;
|
|
66
|
+
const effectiveColumns = columns ?? dynamicColumns;
|
|
67
|
+
|
|
68
|
+
const visible = maxVisible ? items.slice(0, maxVisible) : items;
|
|
69
|
+
const overflow = maxVisible ? Math.max(0, items.length - maxVisible) : 0;
|
|
70
|
+
const canRender = isCompact || columns !== undefined || containerWidth > 0;
|
|
71
|
+
|
|
72
|
+
if (items.length === 0) return null;
|
|
73
|
+
|
|
74
|
+
const renderRows = (rowItems: T[], cols: number) => {
|
|
75
|
+
const rows: T[][] = [];
|
|
76
|
+
for (let i = 0; i < rowItems.length; i += cols) rows.push(rowItems.slice(i, i + cols));
|
|
77
|
+
return rows.map((row, rowIndex) => {
|
|
78
|
+
const spacers =
|
|
79
|
+
row.length < cols
|
|
80
|
+
? Array.from({ length: cols - row.length }, (_, i) => (
|
|
81
|
+
<View key={`spacer-${i}`} style={styles.flexItem} />
|
|
82
|
+
))
|
|
83
|
+
: null;
|
|
84
|
+
return (
|
|
85
|
+
<View key={rowIndex} style={[styles.row, { gap }]}>
|
|
86
|
+
{partialRowAlign === "end" && spacers}
|
|
87
|
+
{row.map((item) => (
|
|
88
|
+
<View key={keyExtractor(item)} style={styles.flexItem}>
|
|
89
|
+
{renderItem(item)}
|
|
90
|
+
</View>
|
|
91
|
+
))}
|
|
92
|
+
{partialRowAlign === "start" && spacers}
|
|
93
|
+
</View>
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<View
|
|
100
|
+
style={[isCompact && styles.compactContainer, { gap }, style]}
|
|
101
|
+
pointerEvents={disablePress ? "none" : "auto"}
|
|
102
|
+
onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
|
|
103
|
+
>
|
|
104
|
+
{canRender &&
|
|
105
|
+
(isCompact
|
|
106
|
+
? visible.map((item) => (
|
|
107
|
+
<React.Fragment key={keyExtractor(item)}>{renderItem(item, itemSize)}</React.Fragment>
|
|
108
|
+
))
|
|
109
|
+
: effectiveColumns !== undefined
|
|
110
|
+
? renderRows(visible, effectiveColumns)
|
|
111
|
+
: null)}
|
|
112
|
+
|
|
113
|
+
{overflow > 0 && (
|
|
114
|
+
<Text size="sm" color="muted" userSelect="none">
|
|
115
|
+
+{overflow}
|
|
116
|
+
</Text>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{footer}
|
|
120
|
+
</View>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface FileThumbnailGridProps {
|
|
125
|
+
files: DisplayFile[];
|
|
126
|
+
itemSize?: number;
|
|
127
|
+
columns?: number;
|
|
128
|
+
minItemWidth?: number;
|
|
129
|
+
gap?: number;
|
|
130
|
+
maxVisible?: number;
|
|
131
|
+
partialRowAlign?: "start" | "end";
|
|
132
|
+
style?: ViewStyle;
|
|
133
|
+
disablePress?: boolean;
|
|
134
|
+
/** Selected file IDs — renders a selection overlay on those thumbnails. */
|
|
135
|
+
selectedIds?: ReadonlySet<string>;
|
|
136
|
+
onFilePress?: (file: DisplayFile) => void;
|
|
137
|
+
onRemove?: (id: string) => void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function FileThumbnailGrid(props: FileThumbnailGridProps) {
|
|
141
|
+
const { files, selectedIds, onFilePress, onRemove, disablePress, ...layout } = props;
|
|
142
|
+
return (
|
|
143
|
+
<ThumbnailGrid
|
|
144
|
+
{...layout}
|
|
145
|
+
items={files}
|
|
146
|
+
keyExtractor={(f) => f.id}
|
|
147
|
+
disablePress={disablePress}
|
|
148
|
+
renderItem={(file, size) => (
|
|
149
|
+
<FileThumbnail
|
|
150
|
+
file={file}
|
|
151
|
+
size={size}
|
|
152
|
+
onPress={disablePress || !onFilePress ? undefined : () => onFilePress(file)}
|
|
153
|
+
onRemove={onRemove ? () => onRemove(file.id) : undefined}
|
|
154
|
+
selected={selectedIds?.has(file.id)}
|
|
155
|
+
/>
|
|
156
|
+
)}
|
|
157
|
+
/>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const styles = StyleSheet.create({
|
|
162
|
+
compactContainer: { flexDirection: "row", flexWrap: "wrap", userSelect: "none" },
|
|
163
|
+
row: { flexDirection: "row" },
|
|
164
|
+
flexItem: { flex: 1 },
|
|
165
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Ecommerce-style image gallery: a large main image plus a thumbnail grid.
|
|
2
|
+
// Click a thumbnail to swap the main image; click the main image to zoom it
|
|
3
|
+
// full-screen via FileGalleryModal. Source-agnostic — takes DisplayFile[], so
|
|
4
|
+
// any caller (record photos, uploads, chat attachments) can use it.
|
|
5
|
+
//
|
|
6
|
+
// Width is measured with onLayout (like FileThumbnailGrid) and the columns are
|
|
7
|
+
// sized from it, so the gallery adapts to the available space instead of
|
|
8
|
+
// relying on percentage/flex-basis resolution (which collapses inside an
|
|
9
|
+
// auto-width parent).
|
|
10
|
+
import { useState } from "react";
|
|
11
|
+
import { View, Image, Pressable } from "react-native";
|
|
12
|
+
import { Text } from "./text";
|
|
13
|
+
import { colors } from "./colors";
|
|
14
|
+
import { ActivityIndicator } from "./activity_indicator";
|
|
15
|
+
import { FileThumbnail, type DisplayFile } from "./file_thumbnail";
|
|
16
|
+
import { FileGalleryModal } from "./file_gallery_modal";
|
|
17
|
+
|
|
18
|
+
const GAP = 12;
|
|
19
|
+
|
|
20
|
+
export interface ImageGalleryProps {
|
|
21
|
+
images: DisplayFile[];
|
|
22
|
+
loading?: boolean;
|
|
23
|
+
/** Shown when there are no images and not loading. Pass a translated string. */
|
|
24
|
+
emptyText?: string;
|
|
25
|
+
/** Optional max width. Omit to fill the container. */
|
|
26
|
+
maxWidth?: number;
|
|
27
|
+
/** Hint shown in the full-screen zoom modal. Pass a translated string. */
|
|
28
|
+
captionHint?: string;
|
|
29
|
+
/** "bottom" stacks thumbnails under the main image; "right" puts the main
|
|
30
|
+
* image (~60% of the width) and the thumbnail grid side by side. */
|
|
31
|
+
thumbnailPosition?: "bottom" | "right";
|
|
32
|
+
/** Aspect ratio (width / height) of the main image box. Default 4/3. A
|
|
33
|
+
* smaller value (e.g. 1) makes a taller, larger preview. */
|
|
34
|
+
mainAspectRatio?: number;
|
|
35
|
+
/** Main image's fraction of the width in "right" mode (0–1). Default 0.6. */
|
|
36
|
+
mainFraction?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ImageGallery({
|
|
40
|
+
images,
|
|
41
|
+
loading = false,
|
|
42
|
+
emptyText = "No images.",
|
|
43
|
+
maxWidth,
|
|
44
|
+
captionHint = "ESC to close · ←/→ to navigate",
|
|
45
|
+
thumbnailPosition = "bottom",
|
|
46
|
+
mainAspectRatio = 4 / 3,
|
|
47
|
+
mainFraction = 0.6,
|
|
48
|
+
}: ImageGalleryProps) {
|
|
49
|
+
const [selected, setSelected] = useState(0);
|
|
50
|
+
const [zoomIdx, setZoomIdx] = useState<number | null>(null);
|
|
51
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
52
|
+
|
|
53
|
+
if (loading) return <ActivityIndicator />;
|
|
54
|
+
if (images.length === 0) return <Text size="sm" color="muted">{emptyText}</Text>;
|
|
55
|
+
|
|
56
|
+
const idx = Math.min(selected, images.length - 1);
|
|
57
|
+
const main = images[idx];
|
|
58
|
+
const right = thumbnailPosition === "right";
|
|
59
|
+
const width = maxWidth ? Math.min(containerWidth, maxWidth) : containerWidth;
|
|
60
|
+
|
|
61
|
+
const mainImage = (
|
|
62
|
+
<Pressable
|
|
63
|
+
onPress={() => setZoomIdx(idx)}
|
|
64
|
+
accessibilityRole="button"
|
|
65
|
+
accessibilityLabel="Zoom image"
|
|
66
|
+
style={{
|
|
67
|
+
width: "100%",
|
|
68
|
+
aspectRatio: mainAspectRatio,
|
|
69
|
+
borderRadius: 8,
|
|
70
|
+
borderWidth: 1,
|
|
71
|
+
borderColor: colors.border,
|
|
72
|
+
backgroundColor: colors.zinc["100"],
|
|
73
|
+
overflow: "hidden",
|
|
74
|
+
cursor: "pointer",
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<Image source={{ uri: main.url }} resizeMode="contain" style={{ width: "100%", height: "100%" }} />
|
|
78
|
+
</Pressable>
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const thumbnails = images.length > 1 ? (
|
|
82
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
|
|
83
|
+
{images.map((img, i) => (
|
|
84
|
+
// Border radius = FileThumbnail's own radius (8) + the 2px border, so
|
|
85
|
+
// the active highlight is concentric with the thumbnail's corners.
|
|
86
|
+
<View
|
|
87
|
+
key={img.id}
|
|
88
|
+
style={{
|
|
89
|
+
borderRadius: 10,
|
|
90
|
+
borderWidth: 2,
|
|
91
|
+
borderColor: i === idx ? colors.zinc["900"] : colors.zinc["200"],
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<FileThumbnail file={img} size={56} onPress={() => setSelected(i)} />
|
|
95
|
+
</View>
|
|
96
|
+
))}
|
|
97
|
+
</View>
|
|
98
|
+
) : null;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<View onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}>
|
|
102
|
+
{width > 0 &&
|
|
103
|
+
(right && thumbnails ? (
|
|
104
|
+
<View style={{ flexDirection: "row", gap: GAP }}>
|
|
105
|
+
<View style={{ width: (width - GAP) * mainFraction }}>{mainImage}</View>
|
|
106
|
+
<View style={{ width: (width - GAP) * (1 - mainFraction) }}>{thumbnails}</View>
|
|
107
|
+
</View>
|
|
108
|
+
) : (
|
|
109
|
+
<View style={{ gap: 8, width }}>
|
|
110
|
+
{mainImage}
|
|
111
|
+
{thumbnails}
|
|
112
|
+
</View>
|
|
113
|
+
))}
|
|
114
|
+
|
|
115
|
+
<FileGalleryModal
|
|
116
|
+
files={images}
|
|
117
|
+
activeIndex={zoomIdx}
|
|
118
|
+
onIndexChange={setZoomIdx}
|
|
119
|
+
captionHint={captionHint}
|
|
120
|
+
/>
|
|
121
|
+
</View>
|
|
122
|
+
);
|
|
123
|
+
}
|
package/src/table.tsx
CHANGED
|
@@ -16,7 +16,7 @@ const stickyHeader = [0];
|
|
|
16
16
|
const CHEVRON_W = 44;
|
|
17
17
|
|
|
18
18
|
export function Table<TRow extends Record<string, unknown>>(props: TableProps<TRow>) {
|
|
19
|
-
const { columns, rows, rowKey, rowStyle, sort, onSortChange, renderDetail, expandedKeys, onToggleRow } = props;
|
|
19
|
+
const { columns, rows, rowKey, rowStyle, sort, onSortChange, renderDetail, expandedKeys, onToggleRow, onRowPress } = props;
|
|
20
20
|
const [internal, setInternal] = useState<Set<string>>(() => new Set());
|
|
21
21
|
const expanded = expandedKeys ?? internal;
|
|
22
22
|
const expandable = !!renderDetail;
|
|
@@ -89,6 +89,14 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
|
|
|
89
89
|
<Icon name={isOpen ? "chevron-up" : "chevron-down"} size={18} color={colors.zinc[400]} />
|
|
90
90
|
</View>
|
|
91
91
|
</Pressable>
|
|
92
|
+
) : onRowPress ? (
|
|
93
|
+
<Pressable
|
|
94
|
+
accessibilityRole="button"
|
|
95
|
+
onPress={() => onRowPress(row)}
|
|
96
|
+
style={({ pressed }) => [styles.bodyRow, pressed ? styles.rowPressed : null, extra]}
|
|
97
|
+
>
|
|
98
|
+
{cells}
|
|
99
|
+
</Pressable>
|
|
92
100
|
) : (
|
|
93
101
|
<View style={[styles.bodyRow, extra]}>{cells}</View>
|
|
94
102
|
)}
|
package/src/table.web.tsx
CHANGED
|
@@ -19,11 +19,14 @@ function colWidth<TRow extends Record<string, unknown>>(col: Column<TRow>): CSSP
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function Table<TRow extends Record<string, unknown>>(props: TableProps<TRow>) {
|
|
22
|
-
const { columns, rows, rowKey, rowStyle, sort, onSortChange, renderDetail, expandedKeys, onToggleRow } = props;
|
|
22
|
+
const { columns, rows, rowKey, rowStyle, sort, onSortChange, renderDetail, expandedKeys, onToggleRow, onRowPress } = props;
|
|
23
23
|
const [internal, setInternal] = useState<Set<string>>(() => new Set());
|
|
24
24
|
const [hoverKey, setHoverKey] = useState<string | null>(null);
|
|
25
25
|
const expanded = expandedKeys ?? internal;
|
|
26
26
|
const expandable = !!renderDetail;
|
|
27
|
+
// A row reacts to clicks for one of two reasons: expansion (renderDetail) or
|
|
28
|
+
// selection (onRowPress). Expansion wins if both are set.
|
|
29
|
+
const pressable = expandable || !!onRowPress;
|
|
27
30
|
|
|
28
31
|
const toggle = (key: string, row: TRow) => {
|
|
29
32
|
onToggleRow?.(key, row);
|
|
@@ -97,8 +100,8 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
|
|
|
97
100
|
const extra = (rowStyle?.(row) as CSSProperties | undefined) ?? undefined;
|
|
98
101
|
const rowStyleFinal: CSSProperties = {
|
|
99
102
|
...bodyRowStyle,
|
|
100
|
-
cursor:
|
|
101
|
-
background: hoverKey === key &&
|
|
103
|
+
cursor: pressable ? "pointer" : "default",
|
|
104
|
+
background: hoverKey === key && pressable ? colors.zinc[50] : colors.white,
|
|
102
105
|
...extra,
|
|
103
106
|
};
|
|
104
107
|
return (
|
|
@@ -106,18 +109,19 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
|
|
|
106
109
|
<div
|
|
107
110
|
role="row"
|
|
108
111
|
onClick={
|
|
109
|
-
|
|
112
|
+
pressable
|
|
110
113
|
? (e: React.MouseEvent) => {
|
|
111
|
-
// Whole-row click
|
|
112
|
-
// (pickers/buttons) — they keep their own behaviour.
|
|
113
|
-
// chevron is the keyboard/AT affordance.
|
|
114
|
+
// Whole-row click acts (expand or select), EXCEPT clicks inside an
|
|
115
|
+
// interactive cell (pickers/buttons) — they keep their own behaviour.
|
|
116
|
+
// The disclosure chevron is the keyboard/AT affordance for expansion.
|
|
114
117
|
if ((e.target as HTMLElement).closest("[data-interactive]")) return;
|
|
115
|
-
toggle(key, row);
|
|
118
|
+
if (expandable) toggle(key, row);
|
|
119
|
+
else onRowPress?.(row);
|
|
116
120
|
}
|
|
117
121
|
: undefined
|
|
118
122
|
}
|
|
119
|
-
onMouseEnter={
|
|
120
|
-
onMouseLeave={
|
|
123
|
+
onMouseEnter={pressable ? () => setHoverKey(key) : undefined}
|
|
124
|
+
onMouseLeave={pressable ? () => setHoverKey((k) => (k === key ? null : k)) : undefined}
|
|
121
125
|
style={rowStyleFinal}
|
|
122
126
|
>
|
|
123
127
|
{columns.map((col) => (
|
|
@@ -165,7 +169,10 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
|
|
|
165
169
|
);
|
|
166
170
|
}
|
|
167
171
|
|
|
168
|
-
|
|
172
|
+
// `maxHeight: 100%` is a no-op in a content-sized parent (a card grows to its
|
|
173
|
+
// rows) but caps the table to a bounded parent (a modal's flex region), so
|
|
174
|
+
// `overflow: auto` then scrolls and the sticky header engages.
|
|
175
|
+
const containerStyle: CSSProperties = { width: "100%", maxHeight: "100%", overflow: "auto" };
|
|
169
176
|
const headerRowStyle: CSSProperties = {
|
|
170
177
|
display: "flex",
|
|
171
178
|
position: "sticky",
|
|
@@ -1,50 +1,46 @@
|
|
|
1
1
|
import { useCallback, useMemo } from "react";
|
|
2
2
|
import { StyleSheet, View } from "react-native";
|
|
3
|
-
import { colors } from "
|
|
4
|
-
import { Text } from "
|
|
5
|
-
import { SearchInput } from "
|
|
6
|
-
import { ActivityIndicator } from "
|
|
7
|
-
import { Dialog } from "
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
ColumnFilter,
|
|
13
|
-
type FilterableColumn,
|
|
14
|
-
type ColumnFilterValue,
|
|
15
|
-
} from "./column_filter";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { SearchInput } from "./search_input";
|
|
6
|
+
import { ActivityIndicator } from "./activity_indicator";
|
|
7
|
+
import { Dialog } from "./dialog";
|
|
8
|
+
import { Table } from "@lotics/ui/table";
|
|
9
|
+
import type { Column, TableSort } from "@lotics/ui/table_types";
|
|
10
|
+
import { Pagination } from "./pagination";
|
|
11
|
+
import { ColumnFilter, type FilterableColumn, type ColumnFilterValue } from "./column_filter";
|
|
16
12
|
|
|
17
|
-
/** A grid column the picker displays. Sorting
|
|
18
|
-
export interface PickerColumn<TRow
|
|
13
|
+
/** A grid column the picker displays. Sorting is opt-in per column. */
|
|
14
|
+
export interface PickerColumn<TRow extends Record<string, unknown>> {
|
|
19
15
|
key: string;
|
|
20
16
|
label: string;
|
|
21
|
-
/** Fixed pixel width. Omit to
|
|
17
|
+
/** Fixed pixel width. Omit to flex. */
|
|
22
18
|
width?: number;
|
|
19
|
+
align?: "left" | "right";
|
|
23
20
|
/** Show a sortable header for this column. Requires `sort`/`onSortChange`. */
|
|
24
21
|
sortable?: boolean;
|
|
25
|
-
/**
|
|
26
|
-
* Cell content for this column. Default: a single-line text of the row's
|
|
27
|
-
* value at `key` (objects render their `label`/`display`/`name`).
|
|
28
|
-
*/
|
|
22
|
+
/** Cell content. Default: the row's value at `key` rendered as text. */
|
|
29
23
|
renderCell?: (row: TRow) => React.ReactNode;
|
|
30
24
|
}
|
|
31
25
|
|
|
32
26
|
/** Single-column sort state — the picker sorts by one column at a time. */
|
|
33
|
-
export interface
|
|
27
|
+
export interface TablePickerSort {
|
|
34
28
|
key: string;
|
|
35
|
-
order:
|
|
29
|
+
order: "asc" | "desc";
|
|
36
30
|
}
|
|
37
31
|
|
|
38
|
-
export interface
|
|
32
|
+
export interface TablePickerProps<TRow extends Record<string, unknown>> {
|
|
39
33
|
open: boolean;
|
|
40
34
|
onOpenChange: (open: boolean) => void;
|
|
41
35
|
/** Heading shown above the search box. */
|
|
42
36
|
title?: string;
|
|
43
37
|
|
|
44
38
|
columns: PickerColumn<TRow>[];
|
|
39
|
+
/** Rows of the current page (the consumer paginates). */
|
|
45
40
|
rows: TRow[];
|
|
46
|
-
/**
|
|
47
|
-
|
|
41
|
+
/** The field holding each row's stable id — returned by selection and used to
|
|
42
|
+
* highlight the picked row (e.g. `"__source_record_id"`). */
|
|
43
|
+
rowKey: keyof TRow & string;
|
|
48
44
|
|
|
49
45
|
/** Currently selected row id, or null. Highlights the matching row. */
|
|
50
46
|
value: string | null;
|
|
@@ -59,16 +55,15 @@ export interface DataGridPickerProps<TRow extends Record<string, unknown>> {
|
|
|
59
55
|
searchPlaceholder?: string;
|
|
60
56
|
|
|
61
57
|
/**
|
|
62
|
-
* Controlled single-column sort. Omit `onSortChange` to disable sorting
|
|
63
|
-
*
|
|
58
|
+
* Controlled single-column sort. Omit `onSortChange` to disable sorting.
|
|
59
|
+
* Toggling a column cycles asc → desc → off.
|
|
64
60
|
*/
|
|
65
|
-
sort?:
|
|
66
|
-
onSortChange?: (sort:
|
|
61
|
+
sort?: TablePickerSort | null;
|
|
62
|
+
onSortChange?: (sort: TablePickerSort | null) => void;
|
|
67
63
|
|
|
68
64
|
/**
|
|
69
|
-
* Per-column
|
|
70
|
-
*
|
|
71
|
-
* `columnFilterToConditions`.
|
|
65
|
+
* Per-column filter pills. The consumer holds `filterValues` (keyed by column
|
|
66
|
+
* key) and maps them to query conditions via `columnFilterToConditions`.
|
|
72
67
|
*/
|
|
73
68
|
filters?: FilterableColumn[];
|
|
74
69
|
filterValues?: Record<string, ColumnFilterValue>;
|
|
@@ -76,15 +71,18 @@ export interface DataGridPickerProps<TRow extends Record<string, unknown>> {
|
|
|
76
71
|
/** Accessible name for the per-filter clear control. Pass a translated string. */
|
|
77
72
|
clearLabel?: string;
|
|
78
73
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
74
|
+
/** Pagination (page-model — the consumer owns the page cursor). */
|
|
75
|
+
page: number;
|
|
76
|
+
pageSize: number;
|
|
77
|
+
/** Total rows across all pages, when known — drives "Page 1 of N". */
|
|
78
|
+
total?: number;
|
|
79
|
+
hasMore: boolean;
|
|
80
|
+
onPageChange: (page: number) => void;
|
|
81
81
|
|
|
82
|
-
/** A request is in flight.
|
|
82
|
+
/** A request is in flight. */
|
|
83
83
|
loading?: boolean;
|
|
84
84
|
/** Message when there are no rows and nothing is loading. */
|
|
85
85
|
emptyLabel?: string;
|
|
86
|
-
|
|
87
|
-
rowHeight?: number;
|
|
88
86
|
testID?: string;
|
|
89
87
|
}
|
|
90
88
|
|
|
@@ -103,22 +101,20 @@ function defaultCellText(value: unknown): string {
|
|
|
103
101
|
return "";
|
|
104
102
|
}
|
|
105
103
|
|
|
106
|
-
const DEFAULT_ROW_HEIGHT = 48;
|
|
107
|
-
|
|
108
104
|
/**
|
|
109
|
-
* A data-agnostic record-style picker: a modal holding a
|
|
110
|
-
*
|
|
111
|
-
* about records/tables/fields — the consumer supplies `columns` + `rows`
|
|
112
|
-
* owns the search/sort/filter/
|
|
113
|
-
* server
|
|
114
|
-
* domain hook (e.g. an app's `
|
|
115
|
-
* with any other row source for a different purpose.
|
|
105
|
+
* A data-agnostic record-style picker: a modal holding a table the user can
|
|
106
|
+
* browse (numbered pages), search, sort, and filter, then pick one row. It knows
|
|
107
|
+
* nothing about records/tables/fields — the consumer supplies `columns` + `rows`
|
|
108
|
+
* (one page) and owns the search/sort/filter/page state (so the same component
|
|
109
|
+
* drives a server query, an in-memory list, or any other source). Compose it
|
|
110
|
+
* with a domain hook (e.g. an app's `usePaginatedQuery`) to build a record
|
|
111
|
+
* picker, or with any other row source for a different purpose.
|
|
116
112
|
*
|
|
117
|
-
* Single-select: clicking
|
|
118
|
-
*
|
|
113
|
+
* Single-select: clicking a row picks its id; the selected row is highlighted;
|
|
114
|
+
* `closeOnSelect` (default) dismisses the modal on pick.
|
|
119
115
|
*/
|
|
120
|
-
export function
|
|
121
|
-
props:
|
|
116
|
+
export function TablePicker<TRow extends Record<string, unknown>>(
|
|
117
|
+
props: TablePickerProps<TRow>,
|
|
122
118
|
) {
|
|
123
119
|
const {
|
|
124
120
|
open,
|
|
@@ -126,7 +122,7 @@ export function DataGridPicker<TRow extends Record<string, unknown>>(
|
|
|
126
122
|
title,
|
|
127
123
|
columns,
|
|
128
124
|
rows,
|
|
129
|
-
|
|
125
|
+
rowKey,
|
|
130
126
|
value,
|
|
131
127
|
onValueChange,
|
|
132
128
|
closeOnSelect = true,
|
|
@@ -139,21 +135,25 @@ export function DataGridPicker<TRow extends Record<string, unknown>>(
|
|
|
139
135
|
filterValues,
|
|
140
136
|
onFilterChange,
|
|
141
137
|
clearLabel,
|
|
142
|
-
|
|
138
|
+
page,
|
|
139
|
+
pageSize,
|
|
140
|
+
total,
|
|
141
|
+
hasMore,
|
|
142
|
+
onPageChange,
|
|
143
143
|
loading = false,
|
|
144
144
|
emptyLabel,
|
|
145
|
-
rowHeight = DEFAULT_ROW_HEIGHT,
|
|
146
145
|
testID,
|
|
147
146
|
} = props;
|
|
148
147
|
|
|
149
148
|
const handleSelect = useCallback(
|
|
150
|
-
(
|
|
151
|
-
onValueChange(
|
|
149
|
+
(row: TRow) => {
|
|
150
|
+
onValueChange(String(row[rowKey]));
|
|
152
151
|
if (closeOnSelect) onOpenChange(false);
|
|
153
152
|
},
|
|
154
|
-
[onValueChange, closeOnSelect, onOpenChange],
|
|
153
|
+
[onValueChange, rowKey, closeOnSelect, onOpenChange],
|
|
155
154
|
);
|
|
156
155
|
|
|
156
|
+
// Toggle this column's sort: asc → desc → off. The consumer holds the state.
|
|
157
157
|
const toggleSort = useCallback(
|
|
158
158
|
(key: string) => {
|
|
159
159
|
if (!onSortChange) return;
|
|
@@ -164,51 +164,35 @@ export function DataGridPicker<TRow extends Record<string, unknown>>(
|
|
|
164
164
|
[sort, onSortChange],
|
|
165
165
|
);
|
|
166
166
|
|
|
167
|
-
const
|
|
167
|
+
const tableColumns = useMemo<Column<TRow>[]>(
|
|
168
168
|
() =>
|
|
169
169
|
columns.map((col) => ({
|
|
170
170
|
key: col.key,
|
|
171
|
-
|
|
171
|
+
label: col.label,
|
|
172
172
|
width: col.width,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
: undefined,
|
|
184
|
-
renderCell: ({ row }: { row: TRow }) => (
|
|
185
|
-
<PressableHighlight
|
|
186
|
-
accessibilityRole="button"
|
|
187
|
-
onPress={() => handleSelect(rowIdGetter(row))}
|
|
188
|
-
style={styles.cell}
|
|
189
|
-
>
|
|
190
|
-
{col.renderCell ? (
|
|
191
|
-
col.renderCell(row)
|
|
192
|
-
) : (
|
|
193
|
-
<Text size="sm" color="zinc-900" numberOfLines={1}>
|
|
194
|
-
{defaultCellText(row[col.key])}
|
|
195
|
-
</Text>
|
|
196
|
-
)}
|
|
197
|
-
</PressableHighlight>
|
|
198
|
-
),
|
|
173
|
+
align: col.align,
|
|
174
|
+
sortable: col.sortable && !!onSortChange,
|
|
175
|
+
renderCell: ({ row }: { row: TRow }) =>
|
|
176
|
+
col.renderCell ? (
|
|
177
|
+
col.renderCell(row)
|
|
178
|
+
) : (
|
|
179
|
+
<Text size="sm" color="zinc-900" numberOfLines={1}>
|
|
180
|
+
{defaultCellText(row[col.key])}
|
|
181
|
+
</Text>
|
|
182
|
+
),
|
|
199
183
|
})),
|
|
200
|
-
[columns,
|
|
184
|
+
[columns, onSortChange],
|
|
201
185
|
);
|
|
202
186
|
|
|
203
|
-
const
|
|
204
|
-
() => (
|
|
205
|
-
[
|
|
187
|
+
const tableSort = useMemo<TableSort<TRow> | null>(
|
|
188
|
+
() => (sort ? { key: sort.key, dir: sort.order } : null),
|
|
189
|
+
[sort],
|
|
206
190
|
);
|
|
207
191
|
|
|
208
|
-
const
|
|
192
|
+
const rowStyle = useCallback(
|
|
209
193
|
(row: TRow) =>
|
|
210
|
-
value != null &&
|
|
211
|
-
[value,
|
|
194
|
+
value != null && String(row[rowKey]) === value ? { backgroundColor: colors.blue["50"] } : undefined,
|
|
195
|
+
[value, rowKey],
|
|
212
196
|
);
|
|
213
197
|
|
|
214
198
|
return (
|
|
@@ -221,7 +205,7 @@ export function DataGridPicker<TRow extends Record<string, unknown>>(
|
|
|
221
205
|
) : null}
|
|
222
206
|
|
|
223
207
|
<SearchInput
|
|
224
|
-
testID="
|
|
208
|
+
testID="table-picker-search"
|
|
225
209
|
value={searchQuery}
|
|
226
210
|
onChangeText={onSearchChange}
|
|
227
211
|
placeholder={searchPlaceholder}
|
|
@@ -243,16 +227,15 @@ export function DataGridPicker<TRow extends Record<string, unknown>>(
|
|
|
243
227
|
) : null}
|
|
244
228
|
|
|
245
229
|
<View style={styles.gridArea}>
|
|
246
|
-
{
|
|
247
|
-
<
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
onEndReached={onEndReached}
|
|
230
|
+
{rows.length > 0 ? (
|
|
231
|
+
<Table<TRow>
|
|
232
|
+
columns={tableColumns}
|
|
233
|
+
rows={rows}
|
|
234
|
+
rowKey={rowKey}
|
|
235
|
+
sort={tableSort}
|
|
236
|
+
onSortChange={onSortChange ? (key) => toggleSort(key as string) : undefined}
|
|
237
|
+
onRowPress={handleSelect}
|
|
238
|
+
rowStyle={rowStyle}
|
|
256
239
|
/>
|
|
257
240
|
) : (
|
|
258
241
|
<View style={styles.center}>
|
|
@@ -265,12 +248,17 @@ export function DataGridPicker<TRow extends Record<string, unknown>>(
|
|
|
265
248
|
)}
|
|
266
249
|
</View>
|
|
267
250
|
)}
|
|
268
|
-
{loading && groups.length > 0 ? (
|
|
269
|
-
<View style={styles.loadingOverlay} pointerEvents="none">
|
|
270
|
-
<ActivityIndicator />
|
|
271
|
-
</View>
|
|
272
|
-
) : null}
|
|
273
251
|
</View>
|
|
252
|
+
|
|
253
|
+
<Pagination
|
|
254
|
+
page={page}
|
|
255
|
+
pageSize={pageSize}
|
|
256
|
+
rowCount={rows.length}
|
|
257
|
+
hasMore={hasMore}
|
|
258
|
+
total={total}
|
|
259
|
+
loading={loading}
|
|
260
|
+
onPageChange={onPageChange}
|
|
261
|
+
/>
|
|
274
262
|
</View>
|
|
275
263
|
</Dialog>
|
|
276
264
|
);
|
|
@@ -297,26 +285,16 @@ const styles = StyleSheet.create({
|
|
|
297
285
|
},
|
|
298
286
|
gridArea: {
|
|
299
287
|
flex: 1,
|
|
288
|
+
minHeight: 0,
|
|
300
289
|
borderWidth: 1,
|
|
301
290
|
borderColor: colors.border,
|
|
302
291
|
borderRadius: 12,
|
|
303
292
|
overflow: "hidden",
|
|
304
293
|
},
|
|
305
|
-
cell: {
|
|
306
|
-
flex: 1,
|
|
307
|
-
height: "100%",
|
|
308
|
-
justifyContent: "center",
|
|
309
|
-
paddingHorizontal: 8,
|
|
310
|
-
},
|
|
311
294
|
center: {
|
|
312
295
|
flex: 1,
|
|
313
296
|
alignItems: "center",
|
|
314
297
|
justifyContent: "center",
|
|
315
298
|
padding: 24,
|
|
316
299
|
},
|
|
317
|
-
loadingOverlay: {
|
|
318
|
-
position: "absolute",
|
|
319
|
-
top: 8,
|
|
320
|
-
right: 8,
|
|
321
|
-
},
|
|
322
300
|
});
|
package/src/table_types.ts
CHANGED
|
@@ -35,6 +35,11 @@ export interface TableProps<TRow extends Record<string, unknown>> {
|
|
|
35
35
|
* parent owns the actual sorting of `rows` — this only drives the indicator. */
|
|
36
36
|
sort?: TableSort<TRow> | null;
|
|
37
37
|
onSortChange?: (key: keyof TRow) => void;
|
|
38
|
+
/** Click-to-select: pressing a row (except `interactive` cells) calls this —
|
|
39
|
+
* e.g. a picker that returns the chosen row. Mutually exclusive with
|
|
40
|
+
* `renderDetail` (a row either expands or selects); `renderDetail` wins if
|
|
41
|
+
* both are set. Pair with `rowStyle` to highlight the selected row. */
|
|
42
|
+
onRowPress?: (row: TRow) => void;
|
|
38
43
|
/** Render an inline detail panel, full-width below the row. When set, the
|
|
39
44
|
* whole row (except `interactive` cells) is click-to-expand. */
|
|
40
45
|
renderDetail?: (row: TRow) => ReactNode;
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { StyleSheet } from "react-native";
|
|
2
|
-
import { colors } from "../colors";
|
|
3
|
-
import { Text } from "../text";
|
|
4
|
-
import { Icon } from "../icon";
|
|
5
|
-
import { PressableHighlight } from "../pressable_highlight";
|
|
6
|
-
|
|
7
|
-
export type SortOrder = "asc" | "desc";
|
|
8
|
-
|
|
9
|
-
export interface SortableHeaderCellProps {
|
|
10
|
-
label: string;
|
|
11
|
-
/** Current sort order for THIS column, or null when another (or no) column
|
|
12
|
-
* is the sort key. */
|
|
13
|
-
order: SortOrder | null;
|
|
14
|
-
/** Toggle this column's sort. The consumer owns sort state and re-queries. */
|
|
15
|
-
onToggle: () => void;
|
|
16
|
-
testID?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* A clickable column header that cycles/toggles sort and shows the direction —
|
|
21
|
-
* drop it into any `DataGrid` column's `renderHeaderCell`. Pure presentation:
|
|
22
|
-
* the consumer holds the sort state and reorders/re-queries the rows. Reusable
|
|
23
|
-
* outside the picker (any sortable grid).
|
|
24
|
-
*/
|
|
25
|
-
export function SortableHeaderCell(props: SortableHeaderCellProps) {
|
|
26
|
-
const { label, order, onToggle, testID } = props;
|
|
27
|
-
const active = order !== null;
|
|
28
|
-
return (
|
|
29
|
-
<PressableHighlight
|
|
30
|
-
testID={testID}
|
|
31
|
-
onPress={onToggle}
|
|
32
|
-
accessibilityRole="button"
|
|
33
|
-
accessibilityLabel={label}
|
|
34
|
-
style={styles.cell}
|
|
35
|
-
>
|
|
36
|
-
<Text size="sm" weight="medium" color={active ? "zinc-900" : "zinc-500"} numberOfLines={1}>
|
|
37
|
-
{label}
|
|
38
|
-
</Text>
|
|
39
|
-
<Icon
|
|
40
|
-
name={order === "asc" ? "chevron-up" : order === "desc" ? "chevron-down" : "chevrons-up-down"}
|
|
41
|
-
size={14}
|
|
42
|
-
color={active ? colors.zinc["700"] : colors.zinc["400"]}
|
|
43
|
-
/>
|
|
44
|
-
</PressableHighlight>
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const styles = StyleSheet.create({
|
|
49
|
-
cell: {
|
|
50
|
-
flexDirection: "row",
|
|
51
|
-
alignItems: "center",
|
|
52
|
-
justifyContent: "space-between",
|
|
53
|
-
gap: 4,
|
|
54
|
-
flex: 1,
|
|
55
|
-
height: "100%",
|
|
56
|
-
paddingHorizontal: 8,
|
|
57
|
-
},
|
|
58
|
-
});
|