@lotics/ui 5.9.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 (97) hide show
  1. package/AGENTS.md +127 -44
  2. package/examples/tpl_approvals.tsx +3 -3
  3. package/examples/tpl_dossier.tsx +5 -4
  4. package/examples/tpl_pipeline.tsx +400 -0
  5. package/examples/tpl_record.tsx +5 -4
  6. package/examples/tpl_shifts.tsx +7 -5
  7. package/examples/tpl_task_board.tsx +473 -0
  8. package/examples/tpl_tasks.tsx +440 -0
  9. package/package.json +10 -5
  10. package/src/accordion.tsx +1 -1
  11. package/src/agent_progress.tsx +1 -1
  12. package/src/agent_run.tsx +1 -1
  13. package/src/breakdown.tsx +2 -2
  14. package/src/button.tsx +12 -3
  15. package/src/calendar/calendar_view.tsx +10 -10
  16. package/src/calendar/month_view.tsx +30 -11
  17. package/src/calendar/time_grid_view.tsx +25 -7
  18. package/src/card_select_item.tsx +11 -13
  19. package/src/check_circle.tsx +88 -0
  20. package/src/checkbox_input.tsx +5 -1
  21. package/src/chip.tsx +1 -1
  22. package/src/chip_group.tsx +1 -1
  23. package/src/column_filter.tsx +3 -2
  24. package/src/combobox.tsx +84 -219
  25. package/src/confidence.tsx +14 -3
  26. package/src/control_surface.ts +22 -7
  27. package/src/data_grid.tsx +159 -0
  28. package/src/date_calendar.tsx +46 -23
  29. package/src/date_field.tsx +10 -2
  30. package/src/date_filter.tsx +5 -9
  31. package/src/date_picker.tsx +78 -26
  32. package/src/date_picker_value.ts +11 -0
  33. package/src/date_range_filter_field.tsx +6 -4
  34. package/src/drawer.tsx +1 -1
  35. package/src/file_dropzone.tsx +5 -0
  36. package/src/file_gallery_modal.tsx +43 -42
  37. package/src/file_preview.web.tsx +25 -11
  38. package/src/file_preview_types.ts +9 -0
  39. package/src/file_row.tsx +5 -1
  40. package/src/file_rows.tsx +6 -2
  41. package/src/file_thumbnail.tsx +13 -4
  42. package/src/file_thumbnail_grid.tsx +5 -0
  43. package/src/files_editor.tsx +232 -0
  44. package/src/filter_chip.tsx +25 -12
  45. package/src/focus_ring_pressable.tsx +34 -0
  46. package/src/form_picker.tsx +3 -3
  47. package/src/gantt/gantt_view.tsx +25 -7
  48. package/src/heatmap.tsx +5 -1
  49. package/src/icon_button.tsx +2 -2
  50. package/src/image_gallery.tsx +8 -10
  51. package/src/index.css +8 -12
  52. package/src/inline_date_picker.tsx +11 -11
  53. package/src/inline_edit.tsx +22 -6
  54. package/src/inline_member_select.tsx +1 -1
  55. package/src/inline_select.tsx +9 -8
  56. package/src/inline_text_input.tsx +4 -1
  57. package/src/link.tsx +11 -1
  58. package/src/link_button.tsx +1 -1
  59. package/src/list_item.tsx +2 -2
  60. package/src/member_select.tsx +9 -4
  61. package/src/menu_button.tsx +2 -2
  62. package/src/menu_list_item.tsx +2 -2
  63. package/src/number_input.tsx +11 -4
  64. package/src/option_list.tsx +211 -0
  65. package/src/peek.tsx +1 -1
  66. package/src/picker.tsx +24 -213
  67. package/src/pressable_highlight.tsx +42 -23
  68. package/src/radio_picker.tsx +2 -2
  69. package/src/range_slider.tsx +35 -7
  70. package/src/react_native.d.ts +3 -0
  71. package/src/scroll_to_bottom.tsx +1 -1
  72. package/src/section.tsx +7 -2
  73. package/src/segmented_control.tsx +2 -2
  74. package/src/select.tsx +299 -0
  75. package/src/sort_header.tsx +1 -1
  76. package/src/sources.tsx +1 -1
  77. package/src/spreadsheet_view.tsx +43 -17
  78. package/src/status_grid.tsx +4 -3
  79. package/src/stepper.tsx +1 -1
  80. package/src/switch.tsx +5 -1
  81. package/src/switch_button.tsx +1 -1
  82. package/src/switcher.tsx +1 -1
  83. package/src/table.tsx +5 -1
  84. package/src/tabs.tsx +2 -2
  85. package/src/text_input_field.tsx +15 -4
  86. package/src/time_picker.tsx +11 -4
  87. package/src/timeline.tsx +1 -1
  88. package/src/use_focus_ring.ts +80 -0
  89. package/src/use_hover.ts +26 -0
  90. package/src/use_list_keyboard_nav.test.ts +71 -0
  91. package/src/use_list_keyboard_nav.ts +52 -5
  92. package/src/use_option_list.test.ts +193 -0
  93. package/src/use_option_list.ts +354 -0
  94. package/src/command_menu.tsx +0 -205
  95. package/src/picker_menu.tsx +0 -355
  96. package/src/tag_input.tsx +0 -203
  97. package/src/time_field.tsx +0 -297
@@ -5,15 +5,14 @@
5
5
  // (pdfjs-dist, @lotics/docx, @lotics/xlsx) are optional peer deps loaded lazily —
6
6
  // so a consumer that only shows images pays nothing extra.
7
7
 
8
- import { useCallback, useEffect, useState, type ReactNode } from "react";
9
- import { Linking, Modal, Platform, Pressable, StyleSheet, View } from "react-native";
8
+ import { useCallback, useEffect, type ReactNode } from "react";
9
+ import { Linking, Modal, Platform, StyleSheet, View } from "react-native";
10
10
  import { Text } from "./text";
11
11
  import { Icon } from "./icon";
12
12
  import { IconButton } from "./icon_button";
13
+ import { Button } from "./button";
14
+ import { FocusRingPressable } from "./focus_ring_pressable";
13
15
  import { Alert } from "./alert";
14
- import { MenuButton } from "./menu_button";
15
- import { Popover, PopoverTrigger, PopoverContent } from "./popover";
16
- import { useScreenSize } from "./use_screen_size";
17
16
  import { colors } from "./colors";
18
17
  import type { DisplayFile } from "./file_thumbnail";
19
18
  import { FilePreview } from "./file_preview";
@@ -65,6 +64,13 @@ export interface FileGalleryModalProps {
65
64
  * forwarded so the override can honor the rotate controls for images.
66
65
  */
67
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;
68
74
  /**
69
75
  * Persist the active image's current view-rotation as a new stored file. When
70
76
  * provided and the active image is rotated, a confirm (✓) control appears next
@@ -93,12 +99,11 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
93
99
  rotation,
94
100
  rotatable = true,
95
101
  renderPreview,
102
+ credentials,
96
103
  onPersistRotation,
97
104
  persisting,
98
105
  } = props;
99
106
  const visible = activeIndex !== null;
100
- const [actionsOpen, setActionsOpen] = useState(false);
101
- const { small } = useScreenSize();
102
107
  // Own the rotation when the host doesn't share one (standalone consumers).
103
108
  const internalRotation = useImageRotation();
104
109
  const rot = rotation ?? internalRotation;
@@ -147,26 +152,24 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
147
152
  const rotated = rot.rotationFor(file.id) !== 0;
148
153
 
149
154
  const handleDownload = () => {
150
- setActionsOpen(false);
151
155
  if (onDownload) {
152
156
  onDownload(file);
153
157
  return;
154
158
  }
155
- // Default: the web fetch+save path (presigned URLs are same-origin to an
156
- // app). On native the web primitive can't run, so open the URL externally.
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.
157
162
  if (Platform.OS === "web") {
158
- void downloadFileFromUrl(file.url, file.filename);
163
+ void downloadFileFromUrl(file.url, file.filename, { credentials });
159
164
  } else {
160
165
  void Linking.openURL(file.url);
161
166
  }
162
167
  };
163
168
  const handleOpenExternal = () => {
164
- setActionsOpen(false);
165
169
  onOpenExternal?.(file);
166
170
  };
167
171
  const handleRemove = () => {
168
172
  if (!onRemove) return;
169
- setActionsOpen(false);
170
173
  Alert.alert(l.removeConfirmTitle, l.removeConfirmMessage(file.filename), [
171
174
  { text: l.cancel, style: "cancel" },
172
175
  {
@@ -231,26 +234,28 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
231
234
  </>
232
235
  ) : null}
233
236
 
234
- <Popover open={actionsOpen} onOpenChange={setActionsOpen}>
235
- <PopoverTrigger>
236
- <IconButton size="lg" icon="ellipsis" tooltip={l.actions} color="secondary" />
237
- </PopoverTrigger>
238
- <PopoverContent small={small}>
239
- <View style={styles.menu}>
240
- <MenuButton icon="download" title={l.download} onPress={handleDownload} />
241
- {onOpenExternal ? (
242
- <MenuButton
243
- icon="external-link"
244
- title={l.openExternal}
245
- onPress={handleOpenExternal}
246
- />
247
- ) : null}
248
- {onRemove ? (
249
- <MenuButton icon="trash" title={l.remove} danger onPress={handleRemove} />
250
- ) : null}
251
- </View>
252
- </PopoverContent>
253
- </Popover>
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}
254
259
 
255
260
  <IconButton size="lg" icon="x" tooltip={l.close} color="secondary" onPress={close} />
256
261
  </View>
@@ -264,29 +269,28 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
264
269
  labels={labels}
265
270
  onError={onError}
266
271
  rotation={rot.rotationFor(file.id)}
272
+ credentials={credentials}
267
273
  />
268
274
  )}
269
275
 
270
276
  {activeIndex > 0 ? (
271
- <Pressable
277
+ <FocusRingPressable accessibilityRole="button"
272
278
  onPress={prev}
273
- accessibilityRole="button"
274
279
  accessibilityLabel={l.previous}
275
280
  style={[styles.nav, styles.navLeft]}
276
281
  >
277
282
  <Icon name="chevron-left" size={28} color={colors.white} />
278
- </Pressable>
283
+ </FocusRingPressable>
279
284
  ) : null}
280
285
 
281
286
  {activeIndex < total - 1 ? (
282
- <Pressable
287
+ <FocusRingPressable accessibilityRole="button"
283
288
  onPress={next}
284
- accessibilityRole="button"
285
289
  accessibilityLabel={l.next}
286
290
  style={[styles.nav, styles.navRight]}
287
291
  >
288
292
  <Icon name="chevron-right" size={28} color={colors.white} />
289
- </Pressable>
293
+ </FocusRingPressable>
290
294
  ) : null}
291
295
  </View>
292
296
  </View>
@@ -318,9 +322,6 @@ const styles = StyleSheet.create({
318
322
  spacer: {
319
323
  flex: 1,
320
324
  },
321
- menu: {
322
- minWidth: 180,
323
- },
324
325
  previewArea: {
325
326
  flex: 1,
326
327
  justifyContent: "center",
@@ -33,7 +33,7 @@ import { colors } from "./colors";
33
33
  * Pure: i18n via `labels` props, errors via `onError` — no lingui/analytics
34
34
  * (purity test). Lifted from the frontend gallery so both share one renderer.
35
35
  */
36
- export function FilePreview({ file, labels, onError, rotation }: FilePreviewProps) {
36
+ export function FilePreview({ file, labels, onError, rotation, credentials }: FilePreviewProps) {
37
37
  const l = resolveLabels(labels);
38
38
 
39
39
  if (isImageMimeType(file.mimeType)) {
@@ -47,10 +47,10 @@ export function FilePreview({ file, labels, onError, rotation }: FilePreviewProp
47
47
  );
48
48
  }
49
49
  if (isPdfMimeType(file.mimeType)) {
50
- return <PdfPreview file={file} labels={l} onError={onError} />;
50
+ return <PdfPreview file={file} labels={l} onError={onError} credentials={credentials} />;
51
51
  }
52
52
  if (isDocxMimeType(file.mimeType)) {
53
- return <WordPreview file={file} labels={l} onError={onError} />;
53
+ return <WordPreview file={file} labels={l} onError={onError} credentials={credentials} />;
54
54
  }
55
55
  if (isVideoMimeType(file.mimeType)) {
56
56
  return <video controls preload="metadata" src={file.url} style={mediaElementStyle} />;
@@ -64,7 +64,7 @@ export function FilePreview({ file, labels, onError, rotation }: FilePreviewProp
64
64
  );
65
65
  }
66
66
  if (isPreviewableMimeType(file.mimeType)) {
67
- return <SpreadsheetView file={file} labels={l} onError={onError} />;
67
+ return <SpreadsheetView file={file} labels={l} onError={onError} credentials={credentials} />;
68
68
  }
69
69
 
70
70
  return (
@@ -89,10 +89,12 @@ function WordPreview({
89
89
  file,
90
90
  labels,
91
91
  onError,
92
+ credentials,
92
93
  }: {
93
94
  file: FilePreviewProps["file"];
94
95
  labels: PreviewLabels;
95
96
  onError: FilePreviewProps["onError"];
97
+ credentials: FilePreviewProps["credentials"];
96
98
  }) {
97
99
  const containerRef = useRef<HTMLDivElement>(null);
98
100
  const [state, setState] = useState<{ loading: boolean; error: string | undefined }>({
@@ -106,7 +108,7 @@ function WordPreview({
106
108
  setState({ loading: true, error: undefined });
107
109
 
108
110
  const run = async () => {
109
- const response = await fetch(file.url, { signal: controller.signal });
111
+ const response = await fetch(file.url, { signal: controller.signal, credentials });
110
112
  if (!response.ok) throw new Error(`Document fetch failed (${response.status})`);
111
113
  const arrayBuffer = await response.arrayBuffer();
112
114
  const bytes = new Uint8Array(arrayBuffer);
@@ -136,7 +138,7 @@ function WordPreview({
136
138
  controller.abort();
137
139
  loadedView?.destroy();
138
140
  };
139
- }, [file.url, file.id, file.mimeType, labels.loadFailed, onError]);
141
+ }, [file.url, file.id, file.mimeType, labels.loadFailed, onError, credentials]);
140
142
 
141
143
  return (
142
144
  <div style={wordContainerStyle}>
@@ -157,7 +159,7 @@ function WordPreview({
157
159
  icon="download"
158
160
  title={labels.download}
159
161
  color="secondary"
160
- onPress={() => void downloadFileFromUrl(file.url, file.filename)}
162
+ onPress={() => void downloadFileFromUrl(file.url, file.filename, { credentials })}
161
163
  />
162
164
  </View>
163
165
  </div>
@@ -246,14 +248,15 @@ async function renderPdf(opts: {
246
248
  scroll: HTMLDivElement;
247
249
  pages: HTMLDivElement;
248
250
  signal: AbortSignal;
251
+ credentials: RequestCredentials | undefined;
249
252
  /** Reports a per-page render failure (the page degrades to a blank slot, but
250
253
  * the error is never swallowed). */
251
254
  reportError: (err: unknown) => void;
252
255
  }): Promise<RenderedPdf> {
253
- const { url, scroll, pages, signal, reportError } = opts;
256
+ const { url, scroll, pages, signal, credentials, reportError } = opts;
254
257
  const pdfjs = await loadPdfjs();
255
258
 
256
- const response = await fetch(url, { signal });
259
+ const response = await fetch(url, { signal, credentials });
257
260
  if (!response.ok) throw new Error(`PDF fetch failed (${response.status})`);
258
261
  const data = new Uint8Array(await response.arrayBuffer());
259
262
  if (signal.aborted) throw new DOMException("Aborted", "AbortError");
@@ -367,10 +370,12 @@ function PdfPreview({
367
370
  file,
368
371
  labels,
369
372
  onError,
373
+ credentials,
370
374
  }: {
371
375
  file: FilePreviewProps["file"];
372
376
  labels: PreviewLabels;
373
377
  onError: FilePreviewProps["onError"];
378
+ credentials: FilePreviewProps["credentials"];
374
379
  }) {
375
380
  const scrollRef = useRef<HTMLDivElement>(null);
376
381
  const pagesRef = useRef<HTMLDivElement>(null);
@@ -394,6 +399,7 @@ function PdfPreview({
394
399
  scroll,
395
400
  pages,
396
401
  signal: controller.signal,
402
+ credentials,
397
403
  reportError: (err) => onError?.(err, { fileId: file.id, mimeType: file.mimeType }),
398
404
  }).then(
399
405
  (result) => {
@@ -420,7 +426,15 @@ function PdfPreview({
420
426
  controller.abort();
421
427
  rendered?.destroy();
422
428
  };
423
- }, [file.url, file.id, file.mimeType, labels.loadFailed, labels.passwordProtected, onError]);
429
+ }, [
430
+ file.url,
431
+ file.id,
432
+ file.mimeType,
433
+ labels.loadFailed,
434
+ labels.passwordProtected,
435
+ onError,
436
+ credentials,
437
+ ]);
424
438
 
425
439
  return (
426
440
  <div style={pdfContainerStyle}>
@@ -444,7 +458,7 @@ function PdfPreview({
444
458
  icon="download"
445
459
  title={labels.download}
446
460
  color="secondary"
447
- onPress={() => void downloadFileFromUrl(file.url, file.filename)}
461
+ onPress={() => void downloadFileFromUrl(file.url, file.filename, { credentials })}
448
462
  />
449
463
  </View>
450
464
  </div>
@@ -30,6 +30,14 @@ 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 {
@@ -45,6 +53,7 @@ export interface GalleryLabels extends PreviewLabels {
45
53
  close: string;
46
54
  previous: string;
47
55
  next: string;
56
+ /** Accessibility label for the per-row ⋯ menu in `FileRows`. */
48
57
  actions: string;
49
58
  openExternal: string;
50
59
  remove: string;
package/src/file_row.tsx CHANGED
@@ -3,6 +3,8 @@ import { Pressable, StyleSheet, View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
5
5
  import { FileBadge } from "./file_badge";
6
+ import { FOCUS_RING } from "./control_surface";
7
+ import { useFocusRing } from "./use_focus_ring";
6
8
 
7
9
  export interface FileRowProps {
8
10
  /** The file / document name — the primary line. */
@@ -38,6 +40,7 @@ export interface FileRowProps {
38
40
  export function FileRow({ name, meta, mimeType, placeholder, isTemplate, onPress, trailing }: FileRowProps) {
39
41
  const [hovered, setHovered] = useState(false);
40
42
  const [pressed, setPressed] = useState(false);
43
+ const { focusVisible, focusProps } = useFocusRing();
41
44
  // Hover via the DOM (not Pressability): RN-Web hands a parent's hover to the
42
45
  // innermost nested pressable, which would stop the wash short of `trailing`.
43
46
  const mouseProps = {
@@ -77,11 +80,12 @@ export function FileRow({ name, meta, mimeType, placeholder, isTemplate, onPress
77
80
  >
78
81
  <Pressable
79
82
  onPress={onPress}
83
+ {...focusProps}
80
84
  onPressIn={() => setPressed(true)}
81
85
  onPressOut={() => setPressed(false)}
82
86
  accessibilityRole="button"
83
87
  accessibilityLabel={`Open ${name}`}
84
- style={styles.door}
88
+ style={[styles.door, focusVisible && { boxShadow: FOCUS_RING }]}
85
89
  >
86
90
  {content}
87
91
  </Pressable>
package/src/file_rows.tsx CHANGED
@@ -34,6 +34,9 @@ export interface FileRowsProps {
34
34
  labels?: Partial<GalleryLabels>;
35
35
  /** Gap between rows. Default 8. */
36
36
  gap?: number;
37
+ /** Credentials mode for the gallery's preview fetches — `"include"` for the
38
+ * host's auth-gated proxy URLs, omitted for an app's presigned URLs. */
39
+ credentials?: RequestCredentials;
37
40
  }
38
41
 
39
42
  /**
@@ -42,7 +45,7 @@ export interface FileRowsProps {
42
45
  * record's documents, a message's files. For an ADD/upload surface use `FileGrid`;
43
46
  * for bare square tiles use `FileThumbnailGrid`.
44
47
  */
45
- export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, onError, labels, gap = 8 }: FileRowsProps) {
48
+ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, onError, labels, gap = 8, credentials }: FileRowsProps) {
46
49
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
47
50
  const l = resolveGalleryLabels(labels);
48
51
 
@@ -53,7 +56,7 @@ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, on
53
56
  onDownload(file);
54
57
  return;
55
58
  }
56
- void downloadFileFromUrl(file.url, file.filename);
59
+ void downloadFileFromUrl(file.url, file.filename, { credentials });
57
60
  };
58
61
 
59
62
  const confirmRemove = (file: DisplayFile) => {
@@ -93,6 +96,7 @@ export function FileRows({ files, meta, onRemove, onOpenExternal, onDownload, on
93
96
  onIndexChange={setActiveIndex}
94
97
  onDownload={onDownload}
95
98
  onOpenExternal={onOpenExternal}
99
+ credentials={credentials}
96
100
  onRemove={onRemove ? (fileId) => {
97
101
  const file = files.find((f) => f.id === fileId);
98
102
  if (file) onRemove(file);
@@ -6,6 +6,8 @@ import { IconButton } from "./icon_button";
6
6
  import { Checkbox } from "./checkbox";
7
7
  import { ActivityIndicator } from "./activity_indicator";
8
8
  import { colors } from "./colors";
9
+ import { FOCUS_RING } from "./control_surface";
10
+ import { useFocusRing } from "./use_focus_ring";
9
11
  import { useCallback, useState } from "react";
10
12
  import {
11
13
  GestureResponderEvent,
@@ -183,9 +185,10 @@ interface DocumentBadgeProps {
183
185
  */
184
186
  export function DocumentBadge(props: DocumentBadgeProps) {
185
187
  const { mimeType, isTemplate, onPress, onLongPress } = props;
188
+ const { focusVisible, focusProps } = useFocusRing();
186
189
 
187
190
  return (
188
- <Pressable onPress={onPress} onLongPress={onLongPress}>
191
+ <Pressable onPress={onPress} onLongPress={onLongPress} {...focusProps} style={focusVisible ? { boxShadow: FOCUS_RING, borderRadius: 6 } : undefined}>
189
192
  <FileBadge mimeType={mimeType} isTemplate={isTemplate} />
190
193
  </Pressable>
191
194
  );
@@ -217,6 +220,7 @@ export function DocumentCard(props: DocumentCardProps) {
217
220
 
218
221
  // Badge takes ~40% of the tile, clamped so tiny/huge tiles stay tasteful.
219
222
  const badgeSize = size !== undefined ? Math.max(30, Math.min(60, Math.round(size * 0.4))) : 40;
223
+ const { focusVisible, focusProps } = useFocusRing();
220
224
 
221
225
  return (
222
226
  <Pressable
@@ -225,9 +229,11 @@ export function DocumentCard(props: DocumentCardProps) {
225
229
  sizeStyle,
226
230
  (state as { hovered?: boolean }).hovered && styles.tileHovered,
227
231
  state.pressed && styles.tilePressed,
232
+ focusVisible && { boxShadow: FOCUS_RING },
228
233
  ]}
229
234
  onPress={onPress}
230
235
  onLongPress={onLongPress}
236
+ {...focusProps}
231
237
  >
232
238
  <FileBadge mimeType={mimeType} size={badgeSize} isTemplate={isTemplate} />
233
239
  <Text size="xs" numberOfLines={1} color="zinc-500" style={styles.documentTileName} userSelect="none">
@@ -264,14 +270,16 @@ export function MediaCard(props: MediaCardProps) {
264
270
  : { width: "100%" as const, height: "100%" as const };
265
271
 
266
272
  const isCompact = size !== undefined && size <= COMPACT_THUMBNAIL_SIZE;
273
+ const { focusVisible, focusProps } = useFocusRing();
267
274
 
268
275
  if (isCompact) {
269
276
  const iconSize = Math.max(12, Math.round(size * 0.55));
270
277
  return (
271
278
  <Pressable
272
- style={({ pressed }) => [styles.mediaCardCompact, sizeStyle, { backgroundColor: color }, pressed && styles.pressedDim]}
279
+ style={({ pressed }) => [styles.mediaCardCompact, sizeStyle, { backgroundColor: color }, pressed && styles.pressedDim, focusVisible && { boxShadow: FOCUS_RING }]}
273
280
  onPress={onPress}
274
281
  onLongPress={onLongPress}
282
+ {...focusProps}
275
283
  >
276
284
  <Icon name={icon} size={iconSize} color={colors.white} />
277
285
  {overlay}
@@ -283,7 +291,7 @@ export function MediaCard(props: MediaCardProps) {
283
291
  const backdrop = iconSize + 20;
284
292
 
285
293
  return (
286
- <Pressable style={[styles.documentCard, sizeStyle]} onPress={onPress} onLongPress={onLongPress}>
294
+ <Pressable style={[styles.documentCard, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]} onPress={onPress} onLongPress={onLongPress} {...focusProps}>
287
295
  {(state) => (
288
296
  <>
289
297
  <View style={[styles.mediaCardBody, { backgroundColor: color }]}>
@@ -323,6 +331,7 @@ interface ImageThumbnailProps {
323
331
  function ImageThumbnail(props: ImageThumbnailProps) {
324
332
  const { file, size, onPress, onLongPress } = props;
325
333
  const [useFallback, setUseFallback] = useState(false);
334
+ const { focusVisible, focusProps } = useFocusRing();
326
335
 
327
336
  // Use thumbnail if available, fallback to full URL
328
337
  const url = !useFallback && file.thumbnailUrl ? file.thumbnailUrl : file.url;
@@ -333,7 +342,7 @@ function ImageThumbnail(props: ImageThumbnailProps) {
333
342
  : { width: "100%" as const, height: "100%" as const };
334
343
 
335
344
  return (
336
- <Pressable onPress={onPress} onLongPress={onLongPress} style={[styles.imageThumbnail, sizeStyle]}>
345
+ <Pressable onPress={onPress} onLongPress={onLongPress} {...focusProps} style={[styles.imageThumbnail, sizeStyle, focusVisible && { boxShadow: FOCUS_RING }]}>
337
346
  {(state) => (
338
347
  <>
339
348
  <Image
@@ -15,6 +15,8 @@ import { View, Pressable, StyleSheet, type ViewStyle } from "react-native";
15
15
  import { Text } from "./text";
16
16
  import { colors } from "./colors";
17
17
  import { FileThumbnail, type DisplayFile, THUMBNAIL_SIZE, COMPACT_THUMBNAIL_SIZE } from "./file_thumbnail";
18
+ import { FOCUS_RING } from "./control_surface";
19
+ import { useFocusRing } from "./use_focus_ring";
18
20
 
19
21
  export interface ThumbnailGridProps<T> {
20
22
  items: T[];
@@ -199,6 +201,7 @@ export function FileThumbnailGrid(props: FileThumbnailGridProps) {
199
201
  function OverflowTile({ size, count, onPress }: { size: number; count: number; onPress?: () => void }) {
200
202
  const compact = size <= COMPACT_THUMBNAIL_SIZE;
201
203
  const box = { width: size, height: size, borderRadius: compact ? 7 : 10 };
204
+ const { focusVisible, focusProps } = useFocusRing();
202
205
  const label = (
203
206
  <Text size={compact ? "xs" : "sm"} weight="medium" color="zinc-700" userSelect="none">
204
207
  +{count}
@@ -210,6 +213,7 @@ function OverflowTile({ size, count, onPress }: { size: number; count: number; o
210
213
  return (
211
214
  <Pressable
212
215
  onPress={onPress}
216
+ {...focusProps}
213
217
  accessibilityRole="button"
214
218
  accessibilityLabel={`+${count}`}
215
219
  style={(state) => [
@@ -217,6 +221,7 @@ function OverflowTile({ size, count, onPress }: { size: number; count: number; o
217
221
  box,
218
222
  (state as { hovered?: boolean }).hovered && styles.overflowHovered,
219
223
  state.pressed && styles.overflowPressed,
224
+ focusVisible && { boxShadow: FOCUS_RING },
220
225
  ]}
221
226
  >
222
227
  {label}