@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
@@ -15,17 +15,25 @@ import { type FilePreviewProps, resolveLabels, type PreviewLabels } from "./file
15
15
  import { SpreadsheetView } from "./spreadsheet_view";
16
16
  import { RotatableImage } from "./rotatable_image";
17
17
  import { downloadFileFromUrl } from "./download";
18
+ import { colors } from "./colors";
18
19
 
19
20
  /**
20
- * Universal inline file preview (web). Renders by MIME type: images, PDF, video
21
- * and audio use browser-native elements; Word via the lazy-loaded `@lotics/docx`
22
- * renderer; Excel/CSV via the read-only `@lotics/xlsx` canvas. Heavy engines are
23
- * dynamic-imported so non-document files never pay their bundle cost.
21
+ * Universal inline file preview (web). Renders by MIME type: images, video and
22
+ * audio use browser-native elements; PDF via the lazy-loaded `pdfjs-dist` canvas
23
+ * renderer; Word via `@lotics/docx`; Excel/CSV via the read-only `@lotics/xlsx`
24
+ * canvas. Heavy engines are dynamic-imported so non-document files never pay
25
+ * their bundle cost.
26
+ *
27
+ * PDF renders bytes to a canvas rather than embedding a native `<iframe>` viewer:
28
+ * a nested PDF browsing context is blocked inside the sandboxed, cross-origin
29
+ * custom-code app iframe (and degrades in mobile webviews), whereas a fetched-and-
30
+ * painted canvas works everywhere — the same in-DOM approach the Word/Excel
31
+ * engines already use.
24
32
  *
25
33
  * Pure: i18n via `labels` props, errors via `onError` — no lingui/analytics
26
34
  * (purity test). Lifted from the frontend gallery so both share one renderer.
27
35
  */
28
- export function FilePreview({ file, labels, onError, rotation }: FilePreviewProps) {
36
+ export function FilePreview({ file, labels, onError, rotation, credentials }: FilePreviewProps) {
29
37
  const l = resolveLabels(labels);
30
38
 
31
39
  if (isImageMimeType(file.mimeType)) {
@@ -39,10 +47,10 @@ export function FilePreview({ file, labels, onError, rotation }: FilePreviewProp
39
47
  );
40
48
  }
41
49
  if (isPdfMimeType(file.mimeType)) {
42
- return <iframe src={file.url} title={file.filename} style={fullFrameStyle} />;
50
+ return <PdfPreview file={file} labels={l} onError={onError} credentials={credentials} />;
43
51
  }
44
52
  if (isDocxMimeType(file.mimeType)) {
45
- return <WordPreview file={file} labels={l} onError={onError} />;
53
+ return <WordPreview file={file} labels={l} onError={onError} credentials={credentials} />;
46
54
  }
47
55
  if (isVideoMimeType(file.mimeType)) {
48
56
  return <video controls preload="metadata" src={file.url} style={mediaElementStyle} />;
@@ -56,7 +64,7 @@ export function FilePreview({ file, labels, onError, rotation }: FilePreviewProp
56
64
  );
57
65
  }
58
66
  if (isPreviewableMimeType(file.mimeType)) {
59
- return <SpreadsheetView file={file} labels={l} onError={onError} />;
67
+ return <SpreadsheetView file={file} labels={l} onError={onError} credentials={credentials} />;
60
68
  }
61
69
 
62
70
  return (
@@ -81,10 +89,12 @@ function WordPreview({
81
89
  file,
82
90
  labels,
83
91
  onError,
92
+ credentials,
84
93
  }: {
85
94
  file: FilePreviewProps["file"];
86
95
  labels: PreviewLabels;
87
96
  onError: FilePreviewProps["onError"];
97
+ credentials: FilePreviewProps["credentials"];
88
98
  }) {
89
99
  const containerRef = useRef<HTMLDivElement>(null);
90
100
  const [state, setState] = useState<{ loading: boolean; error: string | undefined }>({
@@ -98,7 +108,7 @@ function WordPreview({
98
108
  setState({ loading: true, error: undefined });
99
109
 
100
110
  const run = async () => {
101
- const response = await fetch(file.url, { signal: controller.signal });
111
+ const response = await fetch(file.url, { signal: controller.signal, credentials });
102
112
  if (!response.ok) throw new Error(`Document fetch failed (${response.status})`);
103
113
  const arrayBuffer = await response.arrayBuffer();
104
114
  const bytes = new Uint8Array(arrayBuffer);
@@ -128,7 +138,7 @@ function WordPreview({
128
138
  controller.abort();
129
139
  loadedView?.destroy();
130
140
  };
131
- }, [file.url, file.id, file.mimeType, labels.loadFailed, onError]);
141
+ }, [file.url, file.id, file.mimeType, labels.loadFailed, onError, credentials]);
132
142
 
133
143
  return (
134
144
  <div style={wordContainerStyle}>
@@ -149,7 +159,7 @@ function WordPreview({
149
159
  icon="download"
150
160
  title={labels.download}
151
161
  color="secondary"
152
- onPress={() => void downloadFileFromUrl(file.url, file.filename)}
162
+ onPress={() => void downloadFileFromUrl(file.url, file.filename, { credentials })}
153
163
  />
154
164
  </View>
155
165
  </div>
@@ -159,12 +169,339 @@ function WordPreview({
159
169
  );
160
170
  }
161
171
 
172
+ /**
173
+ * pdf.js runs on the MAIN THREAD (no Web Worker): importing the worker entry for
174
+ * its side effect registers `globalThis.pdfjsWorker`, which pdf.js's `PDFWorker`
175
+ * reads to skip real-worker setup entirely — no worker-URL resolution, no
176
+ * `new Worker(...)`, so it bundles identically under Vite (apps) and Metro
177
+ * (frontend). One document at a time, on demand — the same tradeoff `@lotics/xlsx`
178
+ * and `@lotics/docx` already make. Cached so the engine loads once per session.
179
+ */
180
+ let pdfjsModule: Promise<typeof import("pdfjs-dist")> | null = null;
181
+ function loadPdfjs(): Promise<typeof import("pdfjs-dist")> {
182
+ if (!pdfjsModule) {
183
+ pdfjsModule = (async () => {
184
+ const [pdfjs] = await Promise.all([
185
+ import("pdfjs-dist"),
186
+ import("pdfjs-dist/build/pdf.worker.min.mjs"),
187
+ ]);
188
+ return pdfjs;
189
+ })().catch((err) => {
190
+ // Don't cache a rejected import — a transient failure would otherwise
191
+ // poison every later PDF preview. Reset so the next open retries.
192
+ pdfjsModule = null;
193
+ throw err;
194
+ });
195
+ }
196
+ return pdfjsModule;
197
+ }
198
+
199
+ // pdf.js's TextLayer overlays transparent, selectable text spans aligned to the
200
+ // canvas. The spans position themselves off `--total-scale-factor` (set per page
201
+ // below); these are the minimal rules from pdf.js's shipped viewer CSS that make
202
+ // the text invisible, selectable, and correctly placed. Injected once, globally
203
+ // (the `.textLayer` class is pdf.js-specific) so no consumer has to import a CSS
204
+ // file — a half-applied stylesheet would render the spans as visible black text.
205
+ const PDF_TEXT_LAYER_CSS = `
206
+ .textLayer{position:absolute;text-align:initial;inset:0;overflow:clip;opacity:1;line-height:1;-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none;forced-color-adjust:none;transform-origin:0 0;z-index:0;--min-font-size:1;--text-scale-factor:calc(var(--total-scale-factor) * var(--min-font-size));--min-font-size-inv:calc(1 / var(--min-font-size))}
207
+ .textLayer :is(span,br){color:transparent;position:absolute;white-space:pre;cursor:text;transform-origin:0 0;-webkit-user-select:text;-moz-user-select:text;user-select:text}
208
+ .textLayer > :not(.markedContent),.textLayer .markedContent span:not(.markedContent){z-index:1;--font-height:0;font-size:calc(var(--text-scale-factor) * var(--font-height));--scale-x:1;--rotate:0deg;transform:rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv))}
209
+ .textLayer .markedContent{display:contents}
210
+ .textLayer span[role="img"]{-webkit-user-select:none;-moz-user-select:none;user-select:none;cursor:default}
211
+ .textLayer .endOfContent{display:block;position:absolute;inset:100% 0 0;z-index:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;user-select:none}
212
+ `;
213
+
214
+ let pdfTextLayerStylesInjected = false;
215
+ function ensurePdfTextLayerStyles(): void {
216
+ if (pdfTextLayerStylesInjected || typeof document === "undefined") return;
217
+ pdfTextLayerStylesInjected = true;
218
+ const style = document.createElement("style");
219
+ style.setAttribute("data-lotics-pdf-text-layer", "");
220
+ style.textContent = PDF_TEXT_LAYER_CSS;
221
+ document.head.appendChild(style);
222
+ }
223
+
224
+ const PDF_MAX_PAGE_WIDTH = 900;
225
+ const PDF_PAGE_GUTTER = 16;
226
+
227
+ function isPdfPasswordError(err: unknown): boolean {
228
+ return (
229
+ typeof err === "object" &&
230
+ err !== null &&
231
+ "name" in err &&
232
+ (err as { name: unknown }).name === "PasswordException"
233
+ );
234
+ }
235
+
236
+ interface RenderedPdf {
237
+ destroy: () => void;
238
+ }
239
+
240
+ /**
241
+ * Fetch a PDF, parse it with pdf.js, and paint each page to a canvas inside
242
+ * `pages` — lazily, via an IntersectionObserver, so a 200-page document doesn't
243
+ * render every page up front. Each page also gets a transparent text layer for
244
+ * selection/search. Returns a handle that tears everything down on unmount.
245
+ */
246
+ async function renderPdf(opts: {
247
+ url: string;
248
+ scroll: HTMLDivElement;
249
+ pages: HTMLDivElement;
250
+ signal: AbortSignal;
251
+ credentials: RequestCredentials | undefined;
252
+ /** Reports a per-page render failure (the page degrades to a blank slot, but
253
+ * the error is never swallowed). */
254
+ reportError: (err: unknown) => void;
255
+ }): Promise<RenderedPdf> {
256
+ const { url, scroll, pages, signal, credentials, reportError } = opts;
257
+ const pdfjs = await loadPdfjs();
258
+
259
+ const response = await fetch(url, { signal, credentials });
260
+ if (!response.ok) throw new Error(`PDF fetch failed (${response.status})`);
261
+ const data = new Uint8Array(await response.arrayBuffer());
262
+ if (signal.aborted) throw new DOMException("Aborted", "AbortError");
263
+
264
+ const loadingTask = pdfjs.getDocument({ data });
265
+ const doc = await loadingTask.promise;
266
+ if (signal.aborted) {
267
+ void loadingTask.destroy();
268
+ throw new DOMException("Aborted", "AbortError");
269
+ }
270
+
271
+ pages.replaceChildren();
272
+ const targetWidth = Math.min(
273
+ Math.max((scroll.clientWidth || 800) - PDF_PAGE_GUTTER * 2, 240),
274
+ PDF_MAX_PAGE_WIDTH,
275
+ );
276
+ const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
277
+
278
+ const rendered = new Set<number>();
279
+ const renderTasks: Array<{ cancel: () => void }> = [];
280
+
281
+ const renderPage = async (pageNum: number, wrapper: HTMLDivElement): Promise<void> => {
282
+ if (rendered.has(pageNum) || signal.aborted) return;
283
+ rendered.add(pageNum);
284
+ const page = await doc.getPage(pageNum);
285
+ if (signal.aborted) return;
286
+
287
+ const base = page.getViewport({ scale: 1 });
288
+ const scale = targetWidth / base.width;
289
+ const viewport = page.getViewport({ scale });
290
+
291
+ // Backing store at device-pixel resolution; CSS size stays at the layout
292
+ // scale, so the page is crisp on HiDPI. pdf.js does NOT size the canvas from
293
+ // the render viewport — set the backing-store dimensions explicitly.
294
+ const renderViewport = page.getViewport({ scale: scale * dpr });
295
+ const canvas = document.createElement("canvas");
296
+ canvas.width = Math.floor(renderViewport.width);
297
+ canvas.height = Math.floor(renderViewport.height);
298
+ canvas.style.display = "block";
299
+ canvas.style.width = `${viewport.width}px`;
300
+ canvas.style.height = `${viewport.height}px`;
301
+
302
+ wrapper.style.height = `${viewport.height}px`;
303
+ wrapper.style.minHeight = "0px";
304
+ wrapper.replaceChildren(canvas);
305
+
306
+ const task = page.render({ canvas, viewport: renderViewport });
307
+ renderTasks.push(task);
308
+ await task.promise;
309
+ if (signal.aborted) return;
310
+
311
+ const textLayerDiv = document.createElement("div");
312
+ textLayerDiv.className = "textLayer";
313
+ textLayerDiv.style.width = `${viewport.width}px`;
314
+ textLayerDiv.style.height = `${viewport.height}px`;
315
+ textLayerDiv.style.setProperty("--total-scale-factor", String(scale));
316
+ textLayerDiv.style.setProperty("--scale-factor", String(scale));
317
+ const textLayer = new pdfjs.TextLayer({
318
+ textContentSource: page.streamTextContent(),
319
+ container: textLayerDiv,
320
+ viewport,
321
+ });
322
+ await textLayer.render();
323
+ if (signal.aborted) return;
324
+ wrapper.appendChild(textLayerDiv);
325
+ };
326
+
327
+ const observer = new IntersectionObserver(
328
+ (entries) => {
329
+ for (const entry of entries) {
330
+ if (!entry.isIntersecting) continue;
331
+ const wrapper = entry.target as HTMLDivElement;
332
+ observer.unobserve(wrapper);
333
+ // A page-level failure leaves the reserved slot blank but lets the rest
334
+ // of the document scroll — report it (never swallow), don't throw.
335
+ void renderPage(Number(wrapper.dataset.page), wrapper).catch((err: unknown) => {
336
+ if (signal.aborted) return;
337
+ if (err instanceof DOMException && err.name === "AbortError") return;
338
+ reportError(err);
339
+ });
340
+ }
341
+ },
342
+ { root: scroll, rootMargin: "300px 0px" },
343
+ );
344
+
345
+ for (let pageNum = 1; pageNum <= doc.numPages; pageNum += 1) {
346
+ const wrapper = document.createElement("div");
347
+ wrapper.dataset.page = String(pageNum);
348
+ wrapper.style.position = "relative";
349
+ wrapper.style.width = `${targetWidth}px`;
350
+ // Reserve height (letter ratio) so pages don't collapse before they paint.
351
+ wrapper.style.minHeight = `${Math.round(targetWidth * 1.294)}px`;
352
+ wrapper.style.backgroundColor = colors.white;
353
+ wrapper.style.boxShadow = "0 1px 4px rgba(0, 0, 0, 0.18)";
354
+ wrapper.style.borderRadius = "2px";
355
+ wrapper.style.overflow = "hidden";
356
+ pages.appendChild(wrapper);
357
+ observer.observe(wrapper);
358
+ }
359
+
360
+ return {
361
+ destroy: () => {
362
+ observer.disconnect();
363
+ for (const task of renderTasks) task.cancel();
364
+ void loadingTask.destroy();
365
+ },
366
+ };
367
+ }
368
+
369
+ function PdfPreview({
370
+ file,
371
+ labels,
372
+ onError,
373
+ credentials,
374
+ }: {
375
+ file: FilePreviewProps["file"];
376
+ labels: PreviewLabels;
377
+ onError: FilePreviewProps["onError"];
378
+ credentials: FilePreviewProps["credentials"];
379
+ }) {
380
+ const scrollRef = useRef<HTMLDivElement>(null);
381
+ const pagesRef = useRef<HTMLDivElement>(null);
382
+ const [state, setState] = useState<{ loading: boolean; error: string | undefined }>({
383
+ loading: true,
384
+ error: undefined,
385
+ });
386
+
387
+ useEffect(() => {
388
+ const scroll = scrollRef.current;
389
+ const pages = pagesRef.current;
390
+ if (!scroll || !pages) return;
391
+
392
+ const controller = new AbortController();
393
+ let rendered: RenderedPdf | null = null;
394
+ setState({ loading: true, error: undefined });
395
+ ensurePdfTextLayerStyles();
396
+
397
+ renderPdf({
398
+ url: file.url,
399
+ scroll,
400
+ pages,
401
+ signal: controller.signal,
402
+ credentials,
403
+ reportError: (err) => onError?.(err, { fileId: file.id, mimeType: file.mimeType }),
404
+ }).then(
405
+ (result) => {
406
+ if (controller.signal.aborted) {
407
+ result.destroy();
408
+ return;
409
+ }
410
+ rendered = result;
411
+ setState({ loading: false, error: undefined });
412
+ },
413
+ (err: unknown) => {
414
+ if (controller.signal.aborted) return;
415
+ if (err instanceof DOMException && err.name === "AbortError") return;
416
+ const passwordProtected = isPdfPasswordError(err);
417
+ onError?.(err, { fileId: file.id, mimeType: file.mimeType });
418
+ setState({
419
+ loading: false,
420
+ error: passwordProtected ? labels.passwordProtected : labels.loadFailed,
421
+ });
422
+ },
423
+ );
424
+
425
+ return () => {
426
+ controller.abort();
427
+ rendered?.destroy();
428
+ };
429
+ }, [
430
+ file.url,
431
+ file.id,
432
+ file.mimeType,
433
+ labels.loadFailed,
434
+ labels.passwordProtected,
435
+ onError,
436
+ credentials,
437
+ ]);
438
+
439
+ return (
440
+ <div style={pdfContainerStyle}>
441
+ <div ref={scrollRef} style={pdfScrollStyle}>
442
+ <div ref={pagesRef} style={pdfPagesStyle} />
443
+ </div>
444
+ {state.loading && (
445
+ <div style={pdfOverlayStyle}>
446
+ <Text size="sm" color="muted">
447
+
448
+ </Text>
449
+ </div>
450
+ )}
451
+ {state.error !== undefined && (
452
+ <div style={pdfOverlayStyle}>
453
+ <Text size="sm" color="muted">
454
+ {state.error}
455
+ </Text>
456
+ <View style={{ marginTop: 12 }}>
457
+ <Button
458
+ icon="download"
459
+ title={labels.download}
460
+ color="secondary"
461
+ onPress={() => void downloadFileFromUrl(file.url, file.filename, { credentials })}
462
+ />
463
+ </View>
464
+ </div>
465
+ )}
466
+ </div>
467
+ );
468
+ }
469
+
162
470
  const styles = StyleSheet.create({
163
471
  placeholder: { flex: 1, justifyContent: "center", alignItems: "center", gap: 16 },
164
472
  placeholderText: { marginTop: 8 },
165
473
  });
166
474
 
167
- const fullFrameStyle: React.CSSProperties = { width: "100%", height: "100%", border: "none" };
475
+ const pdfContainerStyle: React.CSSProperties = {
476
+ position: "relative",
477
+ display: "flex",
478
+ flexDirection: "column",
479
+ flex: 1,
480
+ minHeight: 0,
481
+ backgroundColor: "#f4f4f5",
482
+ };
483
+ const pdfScrollStyle: React.CSSProperties = {
484
+ flex: 1,
485
+ width: "100%",
486
+ overflowY: "auto",
487
+ overflowX: "hidden",
488
+ };
489
+ const pdfPagesStyle: React.CSSProperties = {
490
+ display: "flex",
491
+ flexDirection: "column",
492
+ alignItems: "center",
493
+ gap: 16,
494
+ padding: 16,
495
+ };
496
+ const pdfOverlayStyle: React.CSSProperties = {
497
+ position: "absolute",
498
+ inset: 0,
499
+ display: "flex",
500
+ flexDirection: "column",
501
+ alignItems: "center",
502
+ justifyContent: "center",
503
+ pointerEvents: "none",
504
+ };
168
505
  const mediaElementStyle: React.CSSProperties = {
169
506
  width: "100%",
170
507
  height: "100%",
@@ -30,8 +30,57 @@ export interface FilePreviewProps {
30
30
  onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
31
31
  /** View rotation in degrees (0/90/180/270) — applied to image previews only. */
32
32
  rotation?: number;
33
+ /**
34
+ * Credentials mode for the byte fetches the PDF / Word / spreadsheet previews
35
+ * make. The host frontend serves files from the auth-gated proxy on a sibling
36
+ * subdomain (`api.lotics.ai`); the session cookie only rides that cross-origin
37
+ * fetch with `"include"`. Custom-code apps fetch presigned URLs — a credentialed
38
+ * request would trip the bucket's CORS — so they omit this and take the default.
39
+ */
40
+ credentials?: RequestCredentials;
33
41
  }
34
42
 
35
43
  export function resolveLabels(labels: Partial<PreviewLabels> | undefined): PreviewLabels {
36
44
  return labels ? { ...defaultPreviewLabels, ...labels } : defaultPreviewLabels;
37
45
  }
46
+
47
+ /**
48
+ * Strings for the full-screen `FileGalleryModal` chrome (toolbar + nav), on top
49
+ * of the inner `PreviewLabels`. Same i18n-free contract: host passes translated
50
+ * strings, English is the fallback.
51
+ */
52
+ export interface GalleryLabels extends PreviewLabels {
53
+ close: string;
54
+ previous: string;
55
+ next: string;
56
+ /** Accessibility label for the per-row ⋯ menu in `FileRows`. */
57
+ actions: string;
58
+ openExternal: string;
59
+ remove: string;
60
+ removeConfirmTitle: string;
61
+ removeConfirmMessage: (filename: string) => string;
62
+ cancel: string;
63
+ rotateLeft: string;
64
+ rotateRight: string;
65
+ saveRotation: string;
66
+ }
67
+
68
+ export const defaultGalleryLabels: GalleryLabels = {
69
+ ...defaultPreviewLabels,
70
+ close: "Close",
71
+ previous: "Previous",
72
+ next: "Next",
73
+ actions: "Actions",
74
+ openExternal: "Open in new tab",
75
+ remove: "Remove",
76
+ removeConfirmTitle: "Remove file",
77
+ removeConfirmMessage: (filename) => `Remove "${filename}"?`,
78
+ cancel: "Cancel",
79
+ rotateLeft: "Rotate left",
80
+ rotateRight: "Rotate right",
81
+ saveRotation: "Save rotation",
82
+ };
83
+
84
+ export function resolveGalleryLabels(labels: Partial<GalleryLabels> | undefined): GalleryLabels {
85
+ return labels ? { ...defaultGalleryLabels, ...labels } : defaultGalleryLabels;
86
+ }
@@ -0,0 +1,118 @@
1
+ import { useState } from "react";
2
+ import { Pressable, StyleSheet, View } from "react-native";
3
+ import { Text } from "./text";
4
+ import { colors } from "./colors";
5
+ import { FileBadge } from "./file_badge";
6
+ import { FOCUS_RING } from "./control_surface";
7
+ import { useFocusRing } from "./use_focus_ring";
8
+
9
+ export interface FileRowProps {
10
+ /** The file / document name — the primary line. */
11
+ name: string;
12
+ /** Secondary line: size, status, timestamp, etc. */
13
+ meta?: string;
14
+ /** File type → the FileBadge glyph + color. Omit (with `placeholder`) for an
15
+ * expected-but-not-yet-provided document. */
16
+ mimeType?: string;
17
+ /** Ghost badge — an expected document that hasn't been provided yet. */
18
+ placeholder?: boolean;
19
+ /** Marks the badge as a template (TMPL). */
20
+ isTemplate?: boolean;
21
+ /** When set, the WHOLE row is a pressable "door" (e.g. open the file), with a
22
+ * hover/press wash. Omit for a static line whose only interaction is `trailing`. */
23
+ onPress?: () => void;
24
+ /** Trailing slot — a status `Badge`, an action `Button`, a timestamp, a remove
25
+ * control. An independently-interactive SIBLING: never swallowed by the row
26
+ * press (a button never nests in the door button). */
27
+ trailing?: React.ReactNode;
28
+ }
29
+
30
+ /**
31
+ * A horizontal file / document LINE: a `FileBadge` (or a same-footprint
32
+ * placeholder) + the full name and an optional meta line, with a composable
33
+ * `trailing` slot. For document checklists, requirement lists, and attachment
34
+ * rows where the name must be readable.
35
+ *
36
+ * `onPress` makes the whole row a pressable door; `trailing` stays a sibling, so
37
+ * a button/menu in it is pressed independently (the door + sibling a11y pattern —
38
+ * see `PressableRow`). For square thumbnail tiles use `FileThumbnailGrid` / `FileGrid`.
39
+ */
40
+ export function FileRow({ name, meta, mimeType, placeholder, isTemplate, onPress, trailing }: FileRowProps) {
41
+ const [hovered, setHovered] = useState(false);
42
+ const [pressed, setPressed] = useState(false);
43
+ const { focusVisible, focusProps } = useFocusRing();
44
+ // Hover via the DOM (not Pressability): RN-Web hands a parent's hover to the
45
+ // innermost nested pressable, which would stop the wash short of `trailing`.
46
+ const mouseProps = {
47
+ onMouseEnter: () => setHovered(true),
48
+ onMouseLeave: () => setHovered(false),
49
+ } as object;
50
+
51
+ const content = (
52
+ <>
53
+ <FileBadge size={30} mimeType={mimeType} placeholder={placeholder} isTemplate={isTemplate} />
54
+ <View style={styles.text}>
55
+ <Text size="sm" weight="medium" numberOfLines={1}>
56
+ {name}
57
+ </Text>
58
+ {meta ? (
59
+ <Text size="xs" color="muted" numberOfLines={1}>
60
+ {meta}
61
+ </Text>
62
+ ) : null}
63
+ </View>
64
+ </>
65
+ );
66
+
67
+ if (!onPress) {
68
+ return (
69
+ <View style={styles.row}>
70
+ {content}
71
+ {trailing}
72
+ </View>
73
+ );
74
+ }
75
+
76
+ return (
77
+ <View
78
+ style={[styles.pressableRow, hovered && styles.rowHovered, pressed && styles.rowPressed]}
79
+ {...mouseProps}
80
+ >
81
+ <Pressable
82
+ onPress={onPress}
83
+ {...focusProps}
84
+ onPressIn={() => setPressed(true)}
85
+ onPressOut={() => setPressed(false)}
86
+ accessibilityRole="button"
87
+ accessibilityLabel={`Open ${name}`}
88
+ style={[styles.door, focusVisible && { boxShadow: FOCUS_RING }]}
89
+ >
90
+ {content}
91
+ </Pressable>
92
+ {trailing}
93
+ </View>
94
+ );
95
+ }
96
+
97
+ const styles = StyleSheet.create({
98
+ row: { flexDirection: "row", alignItems: "center", gap: 12 },
99
+ pressableRow: {
100
+ flexDirection: "row",
101
+ alignItems: "center",
102
+ gap: 12,
103
+ // Negative horizontal margin cancels the padding for the CONTENT's position
104
+ // (badge stays aligned with static rows / the container edge) while the hover
105
+ // wash bleeds 8px outward into the parent's padding — a hit area with no
106
+ // layout shift. The parent is expected to have ≥8px horizontal padding.
107
+ marginHorizontal: -8,
108
+ paddingHorizontal: 8,
109
+ paddingVertical: 6,
110
+ borderRadius: 8,
111
+ ...({ transitionDuration: "0.1s", transitionProperty: "background-color" } as object),
112
+ },
113
+ rowHovered: { backgroundColor: colors.zinc["50"] },
114
+ rowPressed: { backgroundColor: colors.zinc["100"] },
115
+ // The accessible "Open" door — fills the row left of the trailing sibling.
116
+ door: { flex: 1, flexDirection: "row", alignItems: "center", gap: 12 },
117
+ text: { flex: 1, gap: 1 },
118
+ });