@lotics/ui 5.8.0 → 5.10.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.
Files changed (104) hide show
  1. package/AGENTS.md +184 -56
  2. package/examples/tpl_approvals.tsx +3 -3
  3. package/examples/tpl_convert.tsx +11 -23
  4. package/examples/tpl_dossier.tsx +5 -4
  5. package/examples/tpl_order.tsx +1 -1
  6. package/examples/tpl_pipeline.tsx +400 -0
  7. package/examples/tpl_ratedesk.tsx +1 -1
  8. package/examples/tpl_record.tsx +6 -5
  9. package/examples/tpl_shifts.tsx +7 -5
  10. package/examples/tpl_task_board.tsx +473 -0
  11. package/examples/tpl_tasks.tsx +440 -0
  12. package/package.json +19 -6
  13. package/src/accordion.tsx +1 -1
  14. package/src/agent_progress.tsx +1 -1
  15. package/src/agent_run.tsx +1 -1
  16. package/src/breakdown.tsx +2 -2
  17. package/src/button.tsx +12 -3
  18. package/src/calendar/calendar_view.tsx +10 -10
  19. package/src/calendar/month_view.tsx +30 -11
  20. package/src/calendar/time_grid_view.tsx +25 -7
  21. package/src/card_select_item.tsx +11 -13
  22. package/src/check_circle.tsx +88 -0
  23. package/src/checkbox_input.tsx +5 -1
  24. package/src/chip.tsx +1 -1
  25. package/src/chip_group.tsx +1 -1
  26. package/src/column_filter.tsx +3 -2
  27. package/src/combobox.tsx +84 -219
  28. package/src/confidence.tsx +14 -3
  29. package/src/control_surface.ts +22 -7
  30. package/src/data_grid.tsx +159 -0
  31. package/src/date_calendar.tsx +46 -23
  32. package/src/date_field.tsx +10 -2
  33. package/src/date_filter.tsx +5 -9
  34. package/src/date_picker.tsx +78 -26
  35. package/src/date_picker_value.ts +11 -0
  36. package/src/date_range_filter_field.tsx +6 -4
  37. package/src/drawer.tsx +1 -1
  38. package/src/file_dropzone.tsx +5 -0
  39. package/src/file_gallery_modal.tsx +227 -139
  40. package/src/file_grid.tsx +184 -0
  41. package/src/file_preview.web.tsx +349 -12
  42. package/src/file_preview_types.ts +49 -0
  43. package/src/file_row.tsx +118 -0
  44. package/src/file_rows.tsx +109 -0
  45. package/src/file_thumbnail.tsx +132 -81
  46. package/src/file_thumbnail_grid.tsx +135 -55
  47. package/src/files_editor.tsx +232 -0
  48. package/src/filter_chip.tsx +25 -12
  49. package/src/focus_ring_pressable.tsx +34 -0
  50. package/src/form_picker.tsx +3 -3
  51. package/src/gantt/gantt_view.tsx +25 -7
  52. package/src/heatmap.tsx +5 -1
  53. package/src/icon_button.tsx +14 -3
  54. package/src/image_gallery.tsx +8 -14
  55. package/src/index.css +8 -12
  56. package/src/inline_date_picker.tsx +11 -11
  57. package/src/inline_edit.tsx +22 -6
  58. package/src/inline_member_select.tsx +1 -1
  59. package/src/inline_select.tsx +9 -8
  60. package/src/inline_text_input.tsx +4 -1
  61. package/src/link.tsx +11 -1
  62. package/src/link_button.tsx +1 -1
  63. package/src/list_item.tsx +2 -2
  64. package/src/member_select.tsx +9 -4
  65. package/src/menu_button.tsx +2 -2
  66. package/src/menu_list_item.tsx +2 -2
  67. package/src/number_input.tsx +11 -4
  68. package/src/option_list.tsx +211 -0
  69. package/src/pdfjs_worker.d.ts +5 -0
  70. package/src/peek.tsx +1 -1
  71. package/src/picker.tsx +24 -213
  72. package/src/pressable_highlight.tsx +42 -23
  73. package/src/radio_picker.tsx +2 -2
  74. package/src/range_slider.tsx +35 -7
  75. package/src/react_native.d.ts +3 -0
  76. package/src/scroll_to_bottom.tsx +1 -1
  77. package/src/section.tsx +7 -2
  78. package/src/segmented_control.tsx +2 -2
  79. package/src/select.tsx +299 -0
  80. package/src/sort_header.tsx +1 -1
  81. package/src/sources.tsx +1 -1
  82. package/src/spreadsheet_view.tsx +43 -17
  83. package/src/status_grid.tsx +4 -3
  84. package/src/stepper.tsx +1 -1
  85. package/src/switch.tsx +5 -1
  86. package/src/switch_button.tsx +1 -1
  87. package/src/switcher.tsx +1 -1
  88. package/src/table.tsx +5 -1
  89. package/src/tabs.tsx +2 -2
  90. package/src/text_input_field.tsx +15 -4
  91. package/src/time_picker.tsx +11 -4
  92. package/src/timeline.tsx +1 -1
  93. package/src/uploading_thumbnail.tsx +212 -0
  94. package/src/use_focus_ring.ts +80 -0
  95. package/src/use_hover.ts +26 -0
  96. package/src/use_list_keyboard_nav.test.ts +71 -0
  97. package/src/use_list_keyboard_nav.ts +52 -5
  98. package/src/use_option_list.test.ts +193 -0
  99. package/src/use_option_list.ts +354 -0
  100. package/src/command_menu.tsx +0 -205
  101. package/src/hover_action.tsx +0 -54
  102. package/src/picker_menu.tsx +0 -355
  103. package/src/tag_input.tsx +0 -203
  104. package/src/time_field.tsx +0 -297
@@ -1,39 +1,53 @@
1
- // FileGalleryModal — fullscreen file preview with prev/next nav + ESC close.
2
- // Pure primitive: takes DisplayFile[] only, no Lotics domain coupling. Renders
3
- // every previewable type (image/PDF/video/audio/Word/Excel/CSV) via FilePreview,
4
- // whose document engines (@lotics/docx, @lotics/xlsx) are optional peer deps
5
- // loaded lazily so a consumer that only shows images pays nothing extra.
1
+ // FileGalleryModal — full-screen file preview with a toolbar (filename · counter
2
+ // · actions · close), prev/next nav, and ESC close. Pure primitive: takes
3
+ // DisplayFile[] only, no Lotics domain coupling. Renders every previewable type
4
+ // (image/PDF/video/audio/Word/Excel/CSV) via FilePreview, whose document engines
5
+ // (pdfjs-dist, @lotics/docx, @lotics/xlsx) are optional peer deps loaded lazily
6
+ // so a consumer that only shows images pays nothing extra.
6
7
 
7
- import { useCallback, useEffect, useRef } from "react";
8
- import {
9
- Modal,
10
- Pressable,
11
- StyleSheet,
12
- View,
13
- } from "react-native";
8
+ import { useCallback, useEffect, type ReactNode } from "react";
9
+ import { Linking, Modal, Platform, StyleSheet, View } from "react-native";
14
10
  import { Text } from "./text";
15
11
  import { Icon } from "./icon";
12
+ import { IconButton } from "./icon_button";
13
+ import { Button } from "./button";
14
+ import { FocusRingPressable } from "./focus_ring_pressable";
15
+ import { Alert } from "./alert";
16
16
  import { colors } from "./colors";
17
17
  import type { DisplayFile } from "./file_thumbnail";
18
18
  import { FilePreview } from "./file_preview";
19
19
  import { isImageMimeType } from "./mime";
20
20
  import { useImageRotation, type ImageRotation } from "./use_image_rotation";
21
- import type { PreviewLabels } from "./file_preview_types";
21
+ import { resolveGalleryLabels, type GalleryLabels } from "./file_preview_types";
22
+ import { downloadFileFromUrl } from "./download";
22
23
 
23
24
  export interface FileGalleryModalProps {
24
25
  files: DisplayFile[];
25
26
  /** Index of the file currently shown. When null, the modal is closed. */
26
27
  activeIndex: number | null;
27
28
  onIndexChange: (next: number | null) => void;
28
- /**
29
- * Optional caption suffix shown under the file. Useful for hint text like
30
- * "ESC để đóng · ←/→ để chuyển". When omitted, just shows filename + count.
31
- */
32
- captionHint?: string;
33
- /** Translated preview strings; English fallback when omitted. */
34
- labels?: Partial<PreviewLabels>;
29
+ /** Translated chrome + preview strings; English fallback when omitted. */
30
+ labels?: Partial<GalleryLabels>;
35
31
  /** Reported when a file fails to render (host wires to its logger). */
36
32
  onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
33
+ /**
34
+ * Override the toolbar's Download action. Omit ⇒ a built-in default (web:
35
+ * fetch+save the presigned URL; native: open it externally) — correct for a
36
+ * custom-code app's same-origin presigned R2 URLs. The host frontend passes
37
+ * its own, which sends `credentials: "include"` for auth-gated proxy URLs.
38
+ */
39
+ onDownload?: (file: DisplayFile) => void;
40
+ /**
41
+ * Open the active file outside the modal (a new browser tab on the frontend, the
42
+ * SDK's `openExternal` inside a custom-code app — the sandbox can't pop tabs).
43
+ * When omitted, the action is hidden.
44
+ */
45
+ onOpenExternal?: (file: DisplayFile) => void;
46
+ /**
47
+ * Remove the active file. When provided, a Remove action (with a confirm) shows
48
+ * in the actions menu; the host drops the file from its own state.
49
+ */
50
+ onRemove?: (fileId: string) => void;
37
51
  /**
38
52
  * Optional shared rotation state (from `useImageRotation`) — pass it to keep an
39
53
  * inline gallery and this modal in sync. Omitted ⇒ the modal keeps its own
@@ -42,6 +56,21 @@ export interface FileGalleryModalProps {
42
56
  rotation?: ImageRotation;
43
57
  /** Show 90° rotate controls for image files. Default true. */
44
58
  rotatable?: boolean;
59
+ /**
60
+ * Override how the active file's CONTENT is rendered (the toolbar/nav chrome is
61
+ * unchanged). Omit ⇒ the built-in `FilePreview`. The host frontend passes this
62
+ * to route Excel to its live editable workbook while delegating every other
63
+ * type to `FilePreview`; custom-code apps never set it. `rotation` (degrees) is
64
+ * forwarded so the override can honor the rotate controls for images.
65
+ */
66
+ renderPreview?: (file: DisplayFile, opts: { rotation: number }) => ReactNode;
67
+ /**
68
+ * Credentials mode for the built-in `FilePreview`'s byte fetches. The host
69
+ * frontend passes `"include"` so the session cookie rides the cross-origin
70
+ * fetch to its auth-gated proxy; custom-code apps omit it (presigned URLs).
71
+ * Ignored when `renderPreview` is set (the override owns its own fetching).
72
+ */
73
+ credentials?: RequestCredentials;
45
74
  /**
46
75
  * Persist the active image's current view-rotation as a new stored file. When
47
76
  * provided and the active image is rotated, a confirm (✓) control appears next
@@ -54,13 +83,27 @@ export interface FileGalleryModalProps {
54
83
  }
55
84
 
56
85
  /**
57
- * Fullscreen file preview gallery. Renders any previewable MIME type inline
58
- * via FilePreview; unknown types show a download placeholder.
86
+ * Full-screen file preview gallery. Renders any previewable MIME type inline via
87
+ * FilePreview; unknown types show a download placeholder.
59
88
  */
60
89
  export function FileGalleryModal(props: FileGalleryModalProps) {
61
- const { files, activeIndex, onIndexChange, captionHint, labels, onError, rotation, rotatable = true, onPersistRotation, persisting } = props;
90
+ const {
91
+ files,
92
+ activeIndex,
93
+ onIndexChange,
94
+ labels,
95
+ onError,
96
+ onDownload,
97
+ onOpenExternal,
98
+ onRemove,
99
+ rotation,
100
+ rotatable = true,
101
+ renderPreview,
102
+ credentials,
103
+ onPersistRotation,
104
+ persisting,
105
+ } = props;
62
106
  const visible = activeIndex !== null;
63
- const overlayRef = useRef<View | null>(null);
64
107
  // Own the rotation when the host doesn't share one (standalone consumers).
65
108
  const internalRotation = useImageRotation();
66
109
  const rot = rotation ?? internalRotation;
@@ -103,84 +146,152 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
103
146
  const file = files[activeIndex];
104
147
  if (!file) return null;
105
148
 
149
+ const l = resolveGalleryLabels(labels);
106
150
  const isImage = isImageMimeType(file.mimeType);
107
151
  const total = files.length;
108
- const caption = captionHint
109
- ? `${file.filename} · ${activeIndex + 1} / ${total} · ${captionHint}`
110
- : `${file.filename} · ${activeIndex + 1} / ${total}`;
152
+ const rotated = rot.rotationFor(file.id) !== 0;
153
+
154
+ const handleDownload = () => {
155
+ if (onDownload) {
156
+ onDownload(file);
157
+ return;
158
+ }
159
+ // Default: the web fetch+save path. `credentials` rides the host's auth-gated
160
+ // proxy URLs (presigned app URLs are same-origin and omit it). On native the
161
+ // web primitive can't run, so open the URL externally.
162
+ if (Platform.OS === "web") {
163
+ void downloadFileFromUrl(file.url, file.filename, { credentials });
164
+ } else {
165
+ void Linking.openURL(file.url);
166
+ }
167
+ };
168
+ const handleOpenExternal = () => {
169
+ onOpenExternal?.(file);
170
+ };
171
+ const handleRemove = () => {
172
+ if (!onRemove) return;
173
+ Alert.alert(l.removeConfirmTitle, l.removeConfirmMessage(file.filename), [
174
+ { text: l.cancel, style: "cancel" },
175
+ {
176
+ text: l.remove,
177
+ style: "destructive",
178
+ onPress: () => {
179
+ if (total === 1) {
180
+ onRemove(file.id);
181
+ close();
182
+ return;
183
+ }
184
+ // Pre-move before the list shrinks, clamping off the end.
185
+ onIndexChange(activeIndex >= total - 1 ? activeIndex - 1 : activeIndex);
186
+ onRemove(file.id);
187
+ },
188
+ },
189
+ ]);
190
+ };
111
191
 
112
192
  return (
113
193
  <Modal visible transparent onRequestClose={close} animationType="fade">
114
- <View style={styles.overlay} ref={overlayRef}>
115
- <Pressable
116
- accessibilityRole="button"
117
- accessibilityLabel="Close"
118
- onPress={close}
119
- style={StyleSheet.absoluteFill}
120
- />
194
+ <View style={styles.surface}>
195
+ <View style={styles.toolbar}>
196
+ <Text size="sm" weight="medium" numberOfLines={1} style={styles.filename}>
197
+ {file.filename}
198
+ </Text>
199
+ {total > 1 ? (
200
+ <Text size="xs" color="muted" style={styles.counter}>
201
+ {`${activeIndex + 1} / ${total}`}
202
+ </Text>
203
+ ) : null}
204
+ <View style={styles.spacer} />
121
205
 
122
- <View style={styles.previewPanel}>
123
- <FilePreview file={file} labels={labels} onError={onError} rotation={rot.rotationFor(file.id)} />
124
- </View>
206
+ {isImage && rotatable ? (
207
+ <>
208
+ <IconButton
209
+ size="lg"
210
+ icon="rotate-ccw"
211
+ tooltip={l.rotateLeft}
212
+ color="secondary"
213
+ onPress={() => rot.rotate(file.id, -1)}
214
+ />
215
+ <IconButton
216
+ size="lg"
217
+ icon="rotate-cw"
218
+ tooltip={l.rotateRight}
219
+ color="secondary"
220
+ onPress={() => rot.rotate(file.id, 1)}
221
+ />
222
+ {onPersistRotation && rotated ? (
223
+ <IconButton
224
+ size="lg"
225
+ icon="check"
226
+ tooltip={l.saveRotation}
227
+ color="primary"
228
+ disabled={persisting}
229
+ onPress={() => {
230
+ if (!persisting) onPersistRotation(file, rot.rotationFor(file.id));
231
+ }}
232
+ />
233
+ ) : null}
234
+ </>
235
+ ) : null}
125
236
 
126
- {isImage && rotatable ? (
127
- <View style={styles.controls}>
128
- <Pressable
129
- onPress={() => rot.rotate(file.id, -1)}
130
- accessibilityRole="button"
131
- accessibilityLabel="Rotate left"
132
- style={styles.controlButton}
133
- >
134
- <Icon name="rotate-ccw" size={20} color={colors.white} />
135
- </Pressable>
136
- <Pressable
137
- onPress={() => rot.rotate(file.id, 1)}
138
- accessibilityRole="button"
139
- accessibilityLabel="Rotate right"
140
- style={styles.controlButton}
141
- >
142
- <Icon name="rotate-cw" size={20} color={colors.white} />
143
- </Pressable>
144
- {onPersistRotation && rot.rotationFor(file.id) !== 0 ? (
145
- <Pressable
146
- onPress={() => { if (!persisting) onPersistRotation(file, rot.rotationFor(file.id)); }}
147
- accessibilityRole="button"
148
- accessibilityLabel="Save rotation"
149
- disabled={persisting}
150
- style={[styles.controlButton, styles.controlButtonPrimary, persisting && styles.controlButtonBusy]}
151
- >
152
- <Icon name="check" size={20} color={colors.white} />
153
- </Pressable>
154
- ) : null}
155
- </View>
156
- ) : null}
237
+ <Button
238
+ icon="download"
239
+ title={l.download}
240
+ color="secondary"
241
+ onPress={handleDownload}
242
+ />
243
+ {onOpenExternal ? (
244
+ <Button
245
+ icon="external-link"
246
+ title={l.openExternal}
247
+ color="secondary"
248
+ onPress={handleOpenExternal}
249
+ />
250
+ ) : null}
251
+ {onRemove ? (
252
+ <Button
253
+ icon="trash"
254
+ title={l.remove}
255
+ color="danger-secondary"
256
+ onPress={handleRemove}
257
+ />
258
+ ) : null}
157
259
 
158
- {activeIndex > 0 ? (
159
- <Pressable
160
- onPress={prev}
161
- accessibilityRole="button"
162
- accessibilityLabel="Previous"
163
- style={[styles.navButton, styles.navLeft]}
164
- >
165
- <Icon name="chevron-left" size={28} color={colors.white} />
166
- </Pressable>
167
- ) : null}
260
+ <IconButton size="lg" icon="x" tooltip={l.close} color="secondary" onPress={close} />
261
+ </View>
168
262
 
169
- {activeIndex < total - 1 ? (
170
- <Pressable
171
- onPress={next}
172
- accessibilityRole="button"
173
- accessibilityLabel="Next"
174
- style={[styles.navButton, styles.navRight]}
175
- >
176
- <Icon name="chevron-right" size={28} color={colors.white} />
177
- </Pressable>
178
- ) : null}
263
+ <View style={styles.previewArea}>
264
+ {renderPreview ? (
265
+ renderPreview(file, { rotation: rot.rotationFor(file.id) })
266
+ ) : (
267
+ <FilePreview
268
+ file={file}
269
+ labels={labels}
270
+ onError={onError}
271
+ rotation={rot.rotationFor(file.id)}
272
+ credentials={credentials}
273
+ />
274
+ )}
179
275
 
180
- <View style={styles.captionWrap} pointerEvents="none">
181
- <View style={styles.captionPill}>
182
- <Text size="sm" style={styles.captionText}>{caption}</Text>
183
- </View>
276
+ {activeIndex > 0 ? (
277
+ <FocusRingPressable accessibilityRole="button"
278
+ onPress={prev}
279
+ accessibilityLabel={l.previous}
280
+ style={[styles.nav, styles.navLeft]}
281
+ >
282
+ <Icon name="chevron-left" size={28} color={colors.white} />
283
+ </FocusRingPressable>
284
+ ) : null}
285
+
286
+ {activeIndex < total - 1 ? (
287
+ <FocusRingPressable accessibilityRole="button"
288
+ onPress={next}
289
+ accessibilityLabel={l.next}
290
+ style={[styles.nav, styles.navRight]}
291
+ >
292
+ <Icon name="chevron-right" size={28} color={colors.white} />
293
+ </FocusRingPressable>
294
+ ) : null}
184
295
  </View>
185
296
  </View>
186
297
  </Modal>
@@ -188,20 +299,34 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
188
299
  }
189
300
 
190
301
  const styles = StyleSheet.create({
191
- overlay: {
302
+ surface: {
192
303
  flex: 1,
193
- backgroundColor: "rgba(0, 0, 0, 0.88)",
194
- justifyContent: "center",
304
+ backgroundColor: colors.background,
305
+ },
306
+ toolbar: {
307
+ flexDirection: "row",
195
308
  alignItems: "center",
309
+ gap: 8,
310
+ paddingHorizontal: 12,
311
+ paddingVertical: 8,
312
+ borderBottomWidth: 1,
313
+ borderBottomColor: colors.border,
314
+ zIndex: 1,
196
315
  },
197
- previewPanel: {
198
- width: "92%",
199
- height: "86%",
200
- backgroundColor: colors.background,
201
- borderRadius: 8,
202
- overflow: "hidden",
316
+ filename: {
317
+ flexShrink: 1,
318
+ },
319
+ counter: {
320
+ flexShrink: 0,
203
321
  },
204
- navButton: {
322
+ spacer: {
323
+ flex: 1,
324
+ },
325
+ previewArea: {
326
+ flex: 1,
327
+ justifyContent: "center",
328
+ },
329
+ nav: {
205
330
  position: "absolute",
206
331
  top: "50%",
207
332
  transform: [{ translateY: -28 }],
@@ -218,41 +343,4 @@ const styles = StyleSheet.create({
218
343
  navRight: {
219
344
  right: 16,
220
345
  },
221
- controls: {
222
- position: "absolute",
223
- top: 16,
224
- right: 16,
225
- flexDirection: "row",
226
- gap: 8,
227
- },
228
- controlButton: {
229
- width: 44,
230
- height: 44,
231
- borderRadius: 22,
232
- backgroundColor: "rgba(0, 0, 0, 0.4)",
233
- justifyContent: "center",
234
- alignItems: "center",
235
- },
236
- controlButtonPrimary: {
237
- backgroundColor: colors.blue["600"],
238
- },
239
- controlButtonBusy: {
240
- opacity: 0.6,
241
- },
242
- captionWrap: {
243
- position: "absolute",
244
- bottom: 24,
245
- left: 0,
246
- right: 0,
247
- alignItems: "center",
248
- },
249
- captionPill: {
250
- backgroundColor: "rgba(0, 0, 0, 0.7)",
251
- paddingHorizontal: 12,
252
- paddingVertical: 6,
253
- borderRadius: 6,
254
- },
255
- captionText: {
256
- color: colors.white,
257
- },
258
346
  });
@@ -0,0 +1,184 @@
1
+ // FileGrid — the upload-aware file grid: a ThumbnailGrid that interleaves
2
+ // completed files (FileThumbnail) with in-flight uploads (UploadingThumbnail).
3
+ // Pure: the host supplies completed files + a normalized upload queue + callbacks;
4
+ // no domain/upload-transport coupling. Each host wires its own upload mechanism
5
+ // (a custom-code app: app-sdk useAttachments; the frontend: its upload queue) and
6
+ // feeds the result here.
7
+ //
8
+ // Layout: by default tiles FILL the container width (uniform size derived from
9
+ // `minItemWidth`, reflowing as the width changes); a short last row keeps that
10
+ // size and is left-aligned — no stretched tiles, no empty spacer cells. Pass
11
+ // `columns` for a fixed column count, or `itemSize` for exact fixed-size tiles.
12
+
13
+ import { useCallback } from "react";
14
+ import type { ViewStyle } from "react-native";
15
+ import { FileThumbnail, type DisplayFile } from "./file_thumbnail";
16
+ import { ThumbnailGrid } from "./file_thumbnail_grid";
17
+ import { UploadingThumbnail, type UploadStatus, type UploadStatusLabels } from "./uploading_thumbnail";
18
+ import { Button } from "./button";
19
+
20
+ /** An in-flight upload item (everything except a completed file). */
21
+ export interface PendingUpload {
22
+ id: string;
23
+ filename: string;
24
+ mimeType: string;
25
+ previewUrl?: string;
26
+ status: UploadStatus;
27
+ staleFile?: boolean;
28
+ }
29
+
30
+ /** A live upload-queue entry: a just-completed file (in place) or an in-flight
31
+ * upload. The host maps its own upload state to this. */
32
+ export type FileUpload = { status: "complete"; id: string; file: DisplayFile } | PendingUpload;
33
+
34
+ export interface FileGridLabels {
35
+ /** "Retry all" footer button (shown when any upload errored). */
36
+ retryAll?: string;
37
+ /** Status text for in-flight upload tiles (failed / paused / retrying / …). */
38
+ upload?: UploadStatusLabels;
39
+ }
40
+
41
+ export interface FileGridProps {
42
+ /** Pre-existing completed files (e.g. a record's saved attachments). */
43
+ files?: DisplayFile[];
44
+ /** The live add-queue: completed-in-place or in-flight uploads. */
45
+ uploads?: FileUpload[];
46
+ /** EXACT fixed tile size (px) — opts out of fill, for small fixed tiles. */
47
+ itemSize?: number;
48
+ /** FILL with a fixed column count (tiles take an equal 1/N of the width). */
49
+ columns?: number;
50
+ /** FILL target width (default 96): the default mode derives the column count so
51
+ * each tile is ≥ this and rows span the container. */
52
+ minItemWidth?: number;
53
+ gap?: number;
54
+ maxVisible?: number;
55
+ /** SINGLE non-wrapping row, fit-to-width: shows as many tiles as fit, reserving
56
+ * a "+N" overflow tile. For a dense strip in a fixed-width container. */
57
+ singleRow?: boolean;
58
+ /** Press handler for the "+N" overflow tile (the hidden count). Host wires it —
59
+ * e.g. open the gallery at the first hidden file. */
60
+ onOverflowPress?: (hiddenCount: number) => void;
61
+ style?: ViewStyle;
62
+ disablePress?: boolean;
63
+ partialRowAlign?: "start" | "end";
64
+ selectedIds?: ReadonlySet<string>;
65
+ /** When provided, tapping toggles selection instead of `onFilePress`. */
66
+ onToggleSelect?: (id: string) => void;
67
+ /** A completed file was pressed (open it / a gallery). */
68
+ onFilePress?: (file: DisplayFile) => void;
69
+ /** Remove a completed file (its id). */
70
+ onDisplayRemove?: (id: string) => void;
71
+ /** Remove an in-flight upload (its id) — cancels it. */
72
+ onUploadRemove?: (id: string) => void;
73
+ /** Retry one failed upload. */
74
+ onRetry?: (id: string) => void;
75
+ /** Retry all failed uploads. */
76
+ onRetryAll?: () => void;
77
+ labels?: FileGridLabels;
78
+ }
79
+
80
+ type RenderItem =
81
+ | { kind: "display"; file: DisplayFile; removeId: string }
82
+ | { kind: "upload"; removeId: string; upload: PendingUpload };
83
+
84
+ function buildRenderItems(files?: DisplayFile[], uploads?: FileUpload[]): RenderItem[] {
85
+ const items: RenderItem[] = [];
86
+ if (files) {
87
+ for (const file of files) items.push({ kind: "display", file, removeId: file.id });
88
+ }
89
+ if (uploads) {
90
+ for (const upload of uploads) {
91
+ if (upload.status === "complete") {
92
+ items.push({ kind: "display", file: upload.file, removeId: upload.id });
93
+ } else {
94
+ items.push({ kind: "upload", removeId: upload.id, upload });
95
+ }
96
+ }
97
+ }
98
+ return items;
99
+ }
100
+
101
+ export function FileGrid(props: FileGridProps) {
102
+ const {
103
+ files,
104
+ uploads,
105
+ itemSize,
106
+ columns,
107
+ minItemWidth,
108
+ gap = 8,
109
+ maxVisible,
110
+ singleRow,
111
+ onOverflowPress,
112
+ style,
113
+ disablePress,
114
+ partialRowAlign,
115
+ selectedIds,
116
+ onToggleSelect,
117
+ onFilePress,
118
+ onDisplayRemove,
119
+ onUploadRemove,
120
+ onRetry,
121
+ onRetryAll,
122
+ labels,
123
+ } = props;
124
+
125
+ const renderItems = buildRenderItems(files, uploads);
126
+
127
+ const handleFilePress = useCallback(
128
+ (file: DisplayFile) => {
129
+ if (disablePress) return;
130
+ if (onToggleSelect) {
131
+ onToggleSelect(file.id);
132
+ return;
133
+ }
134
+ onFilePress?.(file);
135
+ },
136
+ [disablePress, onToggleSelect, onFilePress],
137
+ );
138
+
139
+ const retryAll =
140
+ onRetryAll && renderItems.some((item) => item.kind === "upload" && item.upload.status === "error") ? (
141
+ <Button onPress={onRetryAll} color="danger" title={labels?.retryAll ?? "Retry all"} />
142
+ ) : null;
143
+
144
+ return (
145
+ <ThumbnailGrid
146
+ items={renderItems}
147
+ keyExtractor={(item) => item.removeId}
148
+ itemSize={itemSize}
149
+ columns={columns}
150
+ minItemWidth={minItemWidth}
151
+ gap={gap}
152
+ maxVisible={maxVisible}
153
+ singleRow={singleRow}
154
+ onOverflowPress={onOverflowPress}
155
+ partialRowAlign={partialRowAlign}
156
+ style={style}
157
+ disablePress={disablePress}
158
+ footer={retryAll}
159
+ renderItem={(item, size) =>
160
+ item.kind === "display" ? (
161
+ <FileThumbnail
162
+ file={item.file}
163
+ size={size}
164
+ onPress={disablePress ? undefined : () => handleFilePress(item.file)}
165
+ onRemove={onDisplayRemove ? () => onDisplayRemove(item.removeId) : undefined}
166
+ selected={selectedIds?.has(item.file.id)}
167
+ />
168
+ ) : (
169
+ <UploadingThumbnail
170
+ filename={item.upload.filename}
171
+ mimeType={item.upload.mimeType}
172
+ previewUrl={item.upload.previewUrl}
173
+ status={item.upload.status}
174
+ staleFile={item.upload.status === "error" ? item.upload.staleFile : undefined}
175
+ size={size}
176
+ onRemove={onUploadRemove ? () => onUploadRemove(item.removeId) : undefined}
177
+ onRetry={onRetry ? () => onRetry(item.removeId) : undefined}
178
+ labels={labels?.upload}
179
+ />
180
+ )
181
+ }
182
+ />
183
+ );
184
+ }