@lotics/ui 1.23.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.23.0",
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",
@@ -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
+ }