@lotics/ui 5.7.0 → 5.9.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 (80) hide show
  1. package/AGENTS.md +102 -42
  2. package/examples/tpl_approvals.tsx +10 -10
  3. package/examples/tpl_convert.tsx +11 -23
  4. package/examples/tpl_crm_desk.tsx +3 -3
  5. package/examples/tpl_dieline.tsx +7 -6
  6. package/examples/tpl_directory.tsx +3 -3
  7. package/examples/tpl_dossier.tsx +7 -7
  8. package/examples/tpl_draft.tsx +4 -4
  9. package/examples/tpl_extract.tsx +7 -7
  10. package/examples/tpl_inventory.tsx +3 -3
  11. package/examples/tpl_lookup.tsx +4 -3
  12. package/examples/tpl_match.tsx +1 -1
  13. package/examples/tpl_order.tsx +1 -1
  14. package/examples/tpl_ratedesk.tsx +12 -12
  15. package/examples/tpl_record.tsx +1 -1
  16. package/examples/tpl_report.tsx +3 -3
  17. package/examples/tpl_stock.tsx +3 -3
  18. package/examples/tpl_triage.tsx +1 -1
  19. package/package.json +12 -4
  20. package/src/agent_progress.tsx +14 -12
  21. package/src/agent_run.tsx +253 -66
  22. package/src/back_button.tsx +3 -2
  23. package/src/badge.tsx +2 -1
  24. package/src/button.tsx +11 -32
  25. package/src/button_colors.ts +23 -0
  26. package/src/calendar/calendar_view.tsx +2 -2
  27. package/src/card_select_item.tsx +2 -1
  28. package/src/change_review.tsx +5 -6
  29. package/src/{pill_button.tsx → chip.tsx} +7 -7
  30. package/src/chip_group.tsx +2 -2
  31. package/src/column_filter.tsx +4 -4
  32. package/src/combobox.tsx +2 -9
  33. package/src/composer.tsx +4 -3
  34. package/src/control_surface.ts +15 -15
  35. package/src/date_field.tsx +2 -1
  36. package/src/date_filter.tsx +2 -1
  37. package/src/date_range_filter_field.tsx +2 -1
  38. package/src/dialog.tsx +2 -2
  39. package/src/discrepancy.tsx +1 -1
  40. package/src/drawer.tsx +1 -2
  41. package/src/file_gallery_modal.tsx +220 -133
  42. package/src/file_grid.tsx +184 -0
  43. package/src/file_preview.web.tsx +329 -6
  44. package/src/file_preview_types.ts +40 -0
  45. package/src/file_row.tsx +114 -0
  46. package/src/file_rows.tsx +105 -0
  47. package/src/file_thumbnail.tsx +121 -79
  48. package/src/file_thumbnail_grid.tsx +130 -55
  49. package/src/{filter_pill.tsx → filter_chip.tsx} +8 -8
  50. package/src/finding.tsx +1 -1
  51. package/src/gantt/gantt_view.tsx +2 -2
  52. package/src/icon_button.tsx +49 -14
  53. package/src/image_gallery.tsx +0 -4
  54. package/src/inline_edit.tsx +2 -2
  55. package/src/link_button.tsx +2 -1
  56. package/src/menu_button.tsx +2 -1
  57. package/src/menu_list_item.tsx +2 -1
  58. package/src/number_input.tsx +2 -1
  59. package/src/pdfjs_worker.d.ts +5 -0
  60. package/src/picker.tsx +3 -3
  61. package/src/popover_header.tsx +2 -2
  62. package/src/radio_picker.tsx +2 -1
  63. package/src/range_slider.tsx +1 -1
  64. package/src/review_card.tsx +2 -2
  65. package/src/scan_field.tsx +2 -1
  66. package/src/scored_option.tsx +0 -1
  67. package/src/search_input.tsx +3 -2
  68. package/src/segmented_control.tsx +2 -1
  69. package/src/status_badge.tsx +2 -1
  70. package/src/stepper.tsx +3 -2
  71. package/src/switch_button.tsx +2 -1
  72. package/src/switcher.tsx +3 -2
  73. package/src/tabs.tsx +2 -1
  74. package/src/tag_input.tsx +2 -1
  75. package/src/text_input_field.tsx +2 -1
  76. package/src/time_field.tsx +2 -1
  77. package/src/time_picker.tsx +2 -1
  78. package/src/triage_row.tsx +3 -3
  79. package/src/uploading_thumbnail.tsx +212 -0
  80. package/src/hover_action.tsx +0 -54
@@ -2,6 +2,7 @@ import { FileBadge, resolveMime } from "./file_badge";
2
2
  import { fontFamilySemiBold } from "./text_utils";
3
3
  import { Text } from "./text";
4
4
  import { Icon, type IconName } from "./icon";
5
+ import { IconButton } from "./icon_button";
5
6
  import { Checkbox } from "./checkbox";
6
7
  import { ActivityIndicator } from "./activity_indicator";
7
8
  import { colors } from "./colors";
@@ -70,6 +71,8 @@ interface FileThumbnailProps {
70
71
  /** While true, dims the thumbnail with a spinner — the file is still uploading
71
72
  * (the local preview shows immediately; this marks it as in-flight). */
72
73
  uploading?: boolean;
74
+ /** Marks the file as a document TEMPLATE — adds a "TMPL" badge (any file type). */
75
+ isTemplate?: boolean;
73
76
  }
74
77
 
75
78
  /**
@@ -77,7 +80,7 @@ interface FileThumbnailProps {
77
80
  * Works with any file source via DisplayFile interface.
78
81
  */
79
82
  export function FileThumbnail(props: FileThumbnailProps) {
80
- const { file, size, onPress, onLongPress, onRemove, selected, uploading } = props;
83
+ const { file, size, onPress, onLongPress, onRemove, selected, uploading, isTemplate } = props;
81
84
 
82
85
  const rootStyle =
83
86
  size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
@@ -101,6 +104,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
101
104
  <DocumentBadge
102
105
  mimeType={file.mimeType}
103
106
  size={size}
107
+ isTemplate={isTemplate}
104
108
  onPress={onPress ?? (() => Linking.openURL(file.url))}
105
109
  onLongPress={onLongPress}
106
110
  />
@@ -126,6 +130,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
126
130
  mimeType={file.mimeType}
127
131
  filename={file.filename}
128
132
  size={size}
133
+ isTemplate={isTemplate}
129
134
  onPress={onPress ?? (() => Linking.openURL(file.url))}
130
135
  onLongPress={onLongPress}
131
136
  />
@@ -157,7 +162,7 @@ const uploadingOverlayStyles = StyleSheet.create({
157
162
  alignItems: "center",
158
163
  justifyContent: "center",
159
164
  backgroundColor: "rgba(24,24,27,0.42)",
160
- borderRadius: 8,
165
+ borderRadius: 10,
161
166
  },
162
167
  });
163
168
 
@@ -190,42 +195,44 @@ interface DocumentCardProps {
190
195
  mimeType: string;
191
196
  filename: string;
192
197
  size?: number;
198
+ isTemplate?: boolean;
193
199
  onPress?: () => void;
194
200
  onLongPress?: () => void;
195
201
  overlay?: React.ReactNode;
196
202
  }
197
203
 
198
204
  /**
199
- * Document preview card: placeholder lines (future: file preview) + footer with filename and type label.
205
+ * Document tile: the file-type badge centered with the filename directly below
206
+ * it (no divider, no footer box). The badge scales with the tile and carries the
207
+ * type, color, and the TMPL marker. For readable filenames in a list, reach for
208
+ * `FileRow` (horizontal) instead.
200
209
  */
201
210
  export function DocumentCard(props: DocumentCardProps) {
202
- const { mimeType, filename, size, onPress, onLongPress, overlay } = props;
203
- const { label, color } = resolveMime(mimeType);
211
+ const { mimeType, filename, size, isTemplate, onPress, onLongPress, overlay } = props;
204
212
 
205
213
  const sizeStyle =
206
214
  size !== undefined
207
215
  ? { width: size, height: size }
208
216
  : { width: "100%" as const, height: "100%" as const };
209
217
 
218
+ // Badge takes ~40% of the tile, clamped so tiny/huge tiles stay tasteful.
219
+ const badgeSize = size !== undefined ? Math.max(30, Math.min(60, Math.round(size * 0.4))) : 40;
220
+
210
221
  return (
211
- <Pressable style={[styles.documentCard, sizeStyle]} onPress={onPress} onLongPress={onLongPress}>
212
- <View style={styles.documentCardBody}>
213
- <View style={styles.placeholderLines}>
214
- <View style={styles.placeholderLine} />
215
- <View style={styles.placeholderLine} />
216
- <View style={[styles.placeholderLine, { width: "60%" }]} />
217
- <View style={styles.placeholderLine} />
218
- <View style={[styles.placeholderLine, { width: "40%" }]} />
219
- </View>
220
- </View>
221
- <View style={styles.documentCardFooter}>
222
- <Text size="xs" numberOfLines={1} color="zinc-500" userSelect="none">
223
- {filename}
224
- </Text>
225
- <View style={[styles.typeLabel, { backgroundColor: color }]}>
226
- <RNText style={styles.typeLabelText}>{label}</RNText>
227
- </View>
228
- </View>
222
+ <Pressable
223
+ style={(state) => [
224
+ styles.documentTile,
225
+ sizeStyle,
226
+ (state as { hovered?: boolean }).hovered && styles.tileHovered,
227
+ state.pressed && styles.tilePressed,
228
+ ]}
229
+ onPress={onPress}
230
+ onLongPress={onLongPress}
231
+ >
232
+ <FileBadge mimeType={mimeType} size={badgeSize} isTemplate={isTemplate} />
233
+ <Text size="xs" numberOfLines={1} color="zinc-500" style={styles.documentTileName} userSelect="none">
234
+ {filename}
235
+ </Text>
229
236
  {overlay}
230
237
  </Pressable>
231
238
  );
@@ -262,7 +269,7 @@ export function MediaCard(props: MediaCardProps) {
262
269
  const iconSize = Math.max(12, Math.round(size * 0.55));
263
270
  return (
264
271
  <Pressable
265
- style={[styles.mediaCardCompact, sizeStyle, { backgroundColor: color }]}
272
+ style={({ pressed }) => [styles.mediaCardCompact, sizeStyle, { backgroundColor: color }, pressed && styles.pressedDim]}
266
273
  onPress={onPress}
267
274
  onLongPress={onLongPress}
268
275
  >
@@ -277,20 +284,25 @@ export function MediaCard(props: MediaCardProps) {
277
284
 
278
285
  return (
279
286
  <Pressable style={[styles.documentCard, sizeStyle]} onPress={onPress} onLongPress={onLongPress}>
280
- <View style={[styles.mediaCardBody, { backgroundColor: color }]}>
281
- <View style={[styles.mediaIconBackdrop, { width: backdrop, height: backdrop, borderRadius: backdrop / 2 }]}>
282
- <Icon name={icon} size={iconSize} color={colors.white} />
283
- </View>
284
- </View>
285
- <View style={styles.documentCardFooter}>
286
- <Text size="xs" numberOfLines={1} color="zinc-500" userSelect="none">
287
- {filename}
288
- </Text>
289
- <View style={[styles.typeLabel, { backgroundColor: color }]}>
290
- <RNText style={styles.typeLabelText}>{label}</RNText>
291
- </View>
292
- </View>
293
- {overlay}
287
+ {(state) => (
288
+ <>
289
+ <View style={[styles.mediaCardBody, { backgroundColor: color }]}>
290
+ <View style={[styles.mediaIconBackdrop, { width: backdrop, height: backdrop, borderRadius: backdrop / 2 }]}>
291
+ <Icon name={icon} size={iconSize} color={colors.white} />
292
+ </View>
293
+ </View>
294
+ <View style={styles.documentCardFooter}>
295
+ <Text size="xs" numberOfLines={1} color="zinc-500" userSelect="none">
296
+ {filename}
297
+ </Text>
298
+ <View style={[styles.typeLabel, { backgroundColor: color }]}>
299
+ <RNText style={styles.typeLabelText}>{label}</RNText>
300
+ </View>
301
+ </View>
302
+ {overlay}
303
+ <TileScrim hovered={(state as { hovered?: boolean }).hovered} pressed={state.pressed} />
304
+ </>
305
+ )}
294
306
  </Pressable>
295
307
  );
296
308
  }
@@ -321,22 +333,31 @@ function ImageThumbnail(props: ImageThumbnailProps) {
321
333
  : { width: "100%" as const, height: "100%" as const };
322
334
 
323
335
  return (
324
- <Pressable
325
- onPress={onPress}
326
- onLongPress={onLongPress}
327
- style={[styles.imageThumbnail, sizeStyle]}
328
- >
329
- <Image
330
- source={{ uri: url }}
331
- style={styles.image}
332
- resizeMode="cover"
333
- onError={() => setUseFallback(true)}
334
- />
336
+ <Pressable onPress={onPress} onLongPress={onLongPress} style={[styles.imageThumbnail, sizeStyle]}>
337
+ {(state) => (
338
+ <>
339
+ <Image
340
+ source={{ uri: url }}
341
+ style={styles.image}
342
+ resizeMode="cover"
343
+ onError={() => setUseFallback(true)}
344
+ />
345
+ <TileScrim hovered={(state as { hovered?: boolean }).hovered} pressed={state.pressed} />
346
+ </>
347
+ )}
335
348
  </Pressable>
336
349
  );
337
350
  }
338
351
 
339
- export function RemoveButton({ onPress }: { onPress: () => void }) {
352
+ /** Hover/press feedback for image and media tiles: a subtle darkening scrim
353
+ * ON TOP of the image/colored body (a bg wash would sit under it). Renders
354
+ * nothing at rest, so it never tints the tile until pointed at. */
355
+ function TileScrim({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) {
356
+ if (!hovered && !pressed) return null;
357
+ return <View style={[styles.tileScrim, pressed && styles.tileScrimPressed]} pointerEvents="none" />;
358
+ }
359
+
360
+ export function RemoveButton({ onPress, label = "Remove" }: { onPress: () => void; label?: string }) {
340
361
  const handlePress = useCallback(
341
362
  (e: GestureResponderEvent) => {
342
363
  e.stopPropagation();
@@ -345,9 +366,9 @@ export function RemoveButton({ onPress }: { onPress: () => void }) {
345
366
  [onPress],
346
367
  );
347
368
  return (
348
- <Pressable onPress={handlePress} style={styles.removeButton}>
349
- <Icon name="x" size={14} color={colors.zinc[700]} />
350
- </Pressable>
369
+ <View style={styles.removeButtonAnchor}>
370
+ <IconButton icon="x" size="sm" elevated accessibilityLabel={label} onPress={handlePress} />
371
+ </View>
351
372
  );
352
373
  }
353
374
 
@@ -370,16 +391,53 @@ function SelectionOverlay({ selected }: { selected: boolean }) {
370
391
 
371
392
  const styles = StyleSheet.create({
372
393
  documentCard: {
373
- borderRadius: 8,
394
+ borderRadius: 10,
374
395
  backgroundColor: colors.white,
375
396
  borderWidth: 1,
376
397
  borderColor: colors.zinc["200"],
377
398
  overflow: "hidden",
378
399
  },
379
- documentCardBody: {
380
- flex: 1,
400
+ documentTile: {
401
+ borderRadius: 10,
402
+ backgroundColor: colors.white,
403
+ borderWidth: 1,
404
+ borderColor: colors.zinc["200"],
405
+ overflow: "hidden",
406
+ alignItems: "center",
381
407
  justifyContent: "center",
382
- padding: 10,
408
+ gap: 8,
409
+ paddingHorizontal: 8,
410
+ paddingVertical: 10,
411
+ ...({ transitionDuration: "0.1s", transitionProperty: "background-color" } as object),
412
+ },
413
+ documentTileName: {
414
+ alignSelf: "stretch",
415
+ textAlign: "center",
416
+ },
417
+ // Hover/press feedback: doc tiles darken their white bg (wash); image/media
418
+ // tiles get a `TileScrim` ON TOP (a bg wash would sit under the image/body).
419
+ tileHovered: {
420
+ backgroundColor: colors.zinc["50"],
421
+ },
422
+ tilePressed: {
423
+ backgroundColor: colors.zinc["100"],
424
+ },
425
+ tileScrim: {
426
+ position: "absolute",
427
+ top: 0,
428
+ left: 0,
429
+ right: 0,
430
+ bottom: 0,
431
+ borderRadius: 10,
432
+ backgroundColor: "rgba(24, 24, 27, 0.06)",
433
+ ...({ transitionDuration: "0.1s", transitionProperty: "background-color" } as object),
434
+ },
435
+ tileScrimPressed: {
436
+ backgroundColor: "rgba(24, 24, 27, 0.12)",
437
+ },
438
+ // Compact media badges (≤32px) are too small for a scrim — a quick opacity dim reads.
439
+ pressedDim: {
440
+ opacity: 0.85,
383
441
  },
384
442
  mediaCardBody: {
385
443
  flex: 1,
@@ -387,7 +445,7 @@ const styles = StyleSheet.create({
387
445
  alignItems: "center",
388
446
  },
389
447
  mediaCardCompact: {
390
- borderRadius: 6,
448
+ borderRadius: 7,
391
449
  alignItems: "center",
392
450
  justifyContent: "center",
393
451
  overflow: "hidden",
@@ -397,14 +455,6 @@ const styles = StyleSheet.create({
397
455
  justifyContent: "center",
398
456
  backgroundColor: "rgba(255, 255, 255, 0.22)",
399
457
  },
400
- placeholderLines: {
401
- gap: 4,
402
- },
403
- placeholderLine: {
404
- height: 2,
405
- borderRadius: 1,
406
- backgroundColor: colors.zinc["200"],
407
- },
408
458
  documentCardFooter: {
409
459
  gap: 2,
410
460
  paddingHorizontal: 8,
@@ -427,25 +477,17 @@ const styles = StyleSheet.create({
427
477
  letterSpacing: 0.3,
428
478
  },
429
479
  imageThumbnail: {
430
- borderRadius: 8,
480
+ borderRadius: 10,
431
481
  overflow: "hidden",
432
482
  },
433
483
  image: {
434
484
  width: "100%",
435
485
  height: "100%",
436
486
  },
437
- removeButton: {
487
+ removeButtonAnchor: {
438
488
  position: "absolute",
439
- top: -6,
440
- right: -6,
441
- width: 22,
442
- height: 22,
443
- alignItems: "center",
444
- justifyContent: "center",
445
- borderRadius: 999,
446
- borderWidth: 1,
447
- borderColor: colors.border,
448
- backgroundColor: colors.zinc["50"],
489
+ top: -8,
490
+ right: -8,
449
491
  zIndex: 1,
450
492
  },
451
493
  selectionOverlay: {
@@ -454,7 +496,7 @@ const styles = StyleSheet.create({
454
496
  left: 0,
455
497
  right: 0,
456
498
  bottom: 0,
457
- borderRadius: 8,
499
+ borderRadius: 10,
458
500
  zIndex: 1,
459
501
  },
460
502
  selectionOverlaySelected: {
@@ -1,8 +1,9 @@
1
1
  // Pure, reusable thumbnail-grid layout + a file-specific specialization.
2
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:
3
+ // `ThumbnailGrid<T>` is the layout engine a flat wrap with two sizing models:
4
+ // EXACT (`itemSize`, fixed small tiles) or FILL (default / `columns`, uniform
5
+ // tiles that span the container width, short last row left-aligned), plus
6
+ // `maxVisible` overflow. It is item-agnostic — pass `renderItem`. `FileThumbnailGrid` is the common case:
6
7
  // a grid of `DisplayFile`s rendered with `FileThumbnail` (any file type —
7
8
  // image/video/audio/doc — via FileThumbnail's own type handling).
8
9
  //
@@ -10,27 +11,36 @@
10
11
  // upload-progress thumbnails) composes `ThumbnailGrid` with its own renderItem,
11
12
  // so the layout lives in exactly one place.
12
13
  import React, { useState } from "react";
13
- import { View, StyleSheet, type ViewStyle } from "react-native";
14
+ import { View, Pressable, StyleSheet, type ViewStyle } from "react-native";
14
15
  import { Text } from "./text";
15
- import { FileThumbnail, type DisplayFile, THUMBNAIL_SIZE } from "./file_thumbnail";
16
+ import { colors } from "./colors";
17
+ import { FileThumbnail, type DisplayFile, THUMBNAIL_SIZE, COMPACT_THUMBNAIL_SIZE } from "./file_thumbnail";
16
18
 
17
19
  export interface ThumbnailGridProps<T> {
18
20
  items: T[];
19
21
  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
+ /** Render one item at the computed tile size (px) always provided. */
22
23
  renderItem: (item: T, size?: number) => React.ReactNode;
23
- /** Fixed pixel size per item flat horizontal wrap. Takes precedence. */
24
+ /** EXACT tile size (px): tiles are exactly this, flat wrap. May leave space at
25
+ * the row's right edge. Use for fixed small tiles; omit to fill the width. */
24
26
  itemSize?: number;
25
- /** Fixed column count items fill 1/N of the row width. */
27
+ /** FILL with a fixed column count: tiles take an equal 1/N share of the width
28
+ * (so full rows span the container). Omit `itemSize` to use. */
26
29
  columns?: number;
27
- /** Minimum item width for auto column count when neither itemSize nor columns
28
- * is set. Default 96. */
30
+ /** FILL target: when neither `itemSize` nor `columns` is set, the column count
31
+ * is derived so each tile is ≥ this width and rows span the container. Default 96. */
29
32
  minItemWidth?: number;
30
33
  /** Gap between items. Default 8. */
31
34
  gap?: number;
32
- /** Max items shown before a "+N" overflow indicator. */
35
+ /** Max items shown before a "+N" overflow indicator (wrap mode). */
33
36
  maxVisible?: number;
37
+ /** SINGLE non-wrapping row that fits as many tiles as the measured width allows,
38
+ * reserving the last slot for a "+N" overflow tile. Sizes tiles by `itemSize`
39
+ * (else `minItemWidth`). The responsive overflow pattern — never wraps. */
40
+ singleRow?: boolean;
41
+ /** Press handler for the "+N" overflow tile (receives the hidden count). When
42
+ * omitted the tile is a passive indicator. */
43
+ onOverflowPress?: (hiddenCount: number) => void;
34
44
  /** Alignment of a partial last row. Default "start". */
35
45
  partialRowAlign?: "start" | "end";
36
46
  style?: ViewStyle;
@@ -50,6 +60,8 @@ export function ThumbnailGrid<T>(props: ThumbnailGridProps<T>) {
50
60
  minItemWidth = THUMBNAIL_SIZE,
51
61
  gap = 8,
52
62
  maxVisible,
63
+ singleRow,
64
+ onOverflowPress,
53
65
  partialRowAlign = "start",
54
66
  style,
55
67
  disablePress,
@@ -58,57 +70,79 @@ export function ThumbnailGrid<T>(props: ThumbnailGridProps<T>) {
58
70
 
59
71
  const [containerWidth, setContainerWidth] = useState(0);
60
72
 
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
73
  if (items.length === 0) return null;
73
74
 
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)}
75
+ // SINGLE-ROW fit-to-width: one non-wrapping row. Fit as many fixed-size tiles as
76
+ // the measured width allows, reserving the last slot for a "+N" overflow tile —
77
+ // the responsive overflow pattern (avatar stack / Finder / Figma). Needs a
78
+ // measured width pass.
79
+ if (singleRow) {
80
+ const size = itemSize ?? minItemWidth;
81
+ const fit = containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + gap) / (size + gap))) : 0;
82
+ const hasOverflow = fit > 0 && items.length > fit;
83
+ const visibleCount = hasOverflow ? Math.max(1, fit - 1) : items.length;
84
+ const hidden = items.length - visibleCount;
85
+ return (
86
+ <View
87
+ style={[styles.singleRow, { gap }, style]}
88
+ pointerEvents={disablePress ? "none" : "auto"}
89
+ onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
90
+ >
91
+ {fit > 0 &&
92
+ items.slice(0, visibleCount).map((item) => (
93
+ <View key={keyExtractor(item)} style={{ width: size }}>
94
+ {renderItem(item, size)}
90
95
  </View>
91
96
  ))}
92
- {partialRowAlign === "start" && spacers}
93
- </View>
94
- );
95
- });
96
- };
97
+ {fit > 0 && hidden > 0 && (
98
+ <OverflowTile
99
+ size={size}
100
+ count={hidden}
101
+ onPress={onOverflowPress ? () => onOverflowPress(hidden) : undefined}
102
+ />
103
+ )}
104
+ {footer}
105
+ </View>
106
+ );
107
+ }
108
+
109
+ // Two sizing models, both a flat wrap:
110
+ // • Exact (`itemSize`): tiles are exactly that size — for fixed small tiles
111
+ // (badges). The row may not reach the container's right edge.
112
+ // • Fill (default, or `columns`): the column count comes from `columns` or is
113
+ // derived from `minItemWidth`, then every tile takes an EQUAL share of the
114
+ // width so full rows span the container exactly. A short last row keeps that
115
+ // same tile size and is left-/right-aligned — no stretched tiles, no empty
116
+ // spacer cells. Needs one measured layout pass before it can render.
117
+ const isExact = itemSize !== undefined;
118
+ const autoColumns =
119
+ containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + gap) / (minItemWidth + gap))) : 0;
120
+ const cols = columns ?? autoColumns;
121
+ const fillSize =
122
+ cols > 0 && containerWidth > 0 ? Math.floor((containerWidth - (cols - 1) * gap) / cols) : 0;
123
+
124
+ const tileSize = isExact ? itemSize : fillSize;
125
+ const canRender = tileSize > 0;
126
+
127
+ const visible = maxVisible ? items.slice(0, maxVisible) : items;
128
+ const overflow = maxVisible ? Math.max(0, items.length - maxVisible) : 0;
97
129
 
98
130
  return (
99
131
  <View
100
- style={[isCompact && styles.compactContainer, { gap }, style]}
132
+ style={[
133
+ styles.container,
134
+ { gap, justifyContent: partialRowAlign === "end" ? "flex-end" : "flex-start" },
135
+ style,
136
+ ]}
101
137
  pointerEvents={disablePress ? "none" : "auto"}
102
138
  onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
103
139
  >
104
140
  {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)}
141
+ visible.map((item) => (
142
+ <View key={keyExtractor(item)} style={{ width: tileSize }}>
143
+ {renderItem(item, tileSize)}
144
+ </View>
145
+ ))}
112
146
 
113
147
  {overflow > 0 && (
114
148
  <Text size="sm" color="muted" userSelect="none">
@@ -128,6 +162,8 @@ export interface FileThumbnailGridProps {
128
162
  minItemWidth?: number;
129
163
  gap?: number;
130
164
  maxVisible?: number;
165
+ singleRow?: boolean;
166
+ onOverflowPress?: (hiddenCount: number) => void;
131
167
  partialRowAlign?: "start" | "end";
132
168
  style?: ViewStyle;
133
169
  disablePress?: boolean;
@@ -158,8 +194,47 @@ export function FileThumbnailGrid(props: FileThumbnailGridProps) {
158
194
  );
159
195
  }
160
196
 
197
+ /** The "+N" overflow cell: a same-size tile showing the hidden count. Clickable
198
+ * when `onPress` is given (the host opens a gallery / expands), else passive. */
199
+ function OverflowTile({ size, count, onPress }: { size: number; count: number; onPress?: () => void }) {
200
+ const compact = size <= COMPACT_THUMBNAIL_SIZE;
201
+ const box = { width: size, height: size, borderRadius: compact ? 7 : 10 };
202
+ const label = (
203
+ <Text size={compact ? "xs" : "sm"} weight="medium" color="zinc-700" userSelect="none">
204
+ +{count}
205
+ </Text>
206
+ );
207
+ if (!onPress) {
208
+ return <View style={[styles.overflowTile, box]}>{label}</View>;
209
+ }
210
+ return (
211
+ <Pressable
212
+ onPress={onPress}
213
+ accessibilityRole="button"
214
+ accessibilityLabel={`+${count}`}
215
+ style={(state) => [
216
+ styles.overflowTile,
217
+ box,
218
+ (state as { hovered?: boolean }).hovered && styles.overflowHovered,
219
+ state.pressed && styles.overflowPressed,
220
+ ]}
221
+ >
222
+ {label}
223
+ </Pressable>
224
+ );
225
+ }
226
+
161
227
  const styles = StyleSheet.create({
162
- compactContainer: { flexDirection: "row", flexWrap: "wrap", userSelect: "none" },
163
- row: { flexDirection: "row" },
164
- flexItem: { flex: 1 },
228
+ container: { flexDirection: "row", flexWrap: "wrap", userSelect: "none" },
229
+ singleRow: { flexDirection: "row", alignItems: "center", userSelect: "none" },
230
+ overflowTile: {
231
+ backgroundColor: colors.zinc["100"],
232
+ borderWidth: 1,
233
+ borderColor: colors.zinc["200"],
234
+ alignItems: "center",
235
+ justifyContent: "center",
236
+ ...({ transitionDuration: "0.1s", transitionProperty: "background-color" } as object),
237
+ },
238
+ overflowHovered: { backgroundColor: colors.zinc["200"] },
239
+ overflowPressed: { backgroundColor: colors.zinc["300"] },
165
240
  });
@@ -4,11 +4,11 @@ import { Text } from "./text";
4
4
  import { Icon } from "./icon";
5
5
  import { colors } from "./colors";
6
6
  import { LinkButton } from "./link_button";
7
- import { PillButton } from "./pill_button";
7
+ import { Chip } from "./chip";
8
8
  import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
9
9
  import type { PopoverSide, PopoverAlign } from "./popover";
10
10
 
11
- export interface FilterPillProps {
11
+ export interface FilterChipProps {
12
12
  /** The dimension name — shown alone when inactive ("Owner"), prefixed when
13
13
  * active ("Owner: Maria, James"). */
14
14
  label: string;
@@ -22,7 +22,7 @@ export interface FilterPillProps {
22
22
  /** Label for the clear affordances (pass a translated string). */
23
23
  clearLabel?: string;
24
24
  /** The editor revealed on press — a premium primitive (`RangeSlider`,
25
- * `Counter`, `PickerMenu` multi) or any composed control. `FilterPill` owns
25
+ * `Counter`, `PickerMenu` multi) or any composed control. `FilterChip` owns
26
26
  * the pill + popover chrome; the consumer owns the value and applies it.
27
27
  * Pass a render function to receive `close` — the one editor that closes on
28
28
  * action is single-select (`<PickerMenu>` → close on pick); range/counter/
@@ -39,7 +39,7 @@ export interface FilterPillProps {
39
39
  }
40
40
 
41
41
  /**
42
- * A short summary of a multi-select for a `FilterPill` preview — "Maria, James"
42
+ * A short summary of a multi-select for a `FilterChip` preview — "Maria, James"
43
43
  * up to `max` labels, else "N selected", and `undefined` when nothing is
44
44
  * chosen. The select sibling of `rangeSummary`.
45
45
  */
@@ -59,7 +59,7 @@ export function selectSummary(
59
59
  * filter dimension, the sibling of `ChipGroup` (which lays ONE hot dimension's
60
60
  * options out inline). Many dimensions stay scannable because each collapses to
61
61
  * a single pill: inactive reads "Label ⌄", active reads "Label: summary" with an
62
- * × to clear. `FilterPill` bakes the consistent chrome — the preview pill, a
62
+ * × to clear. `FilterChip` bakes the consistent chrome — the preview pill, a
63
63
  * padded popover body for the composed editor, and a Clear footer when active —
64
64
  * so every filter looks and behaves the same. Drop a premium primitive inside
65
65
  * (`RangeSlider`, `Counter`, `PickerMenu` multi). With `footer` (+ controlled
@@ -67,7 +67,7 @@ export function selectSummary(
67
67
  * setting gate with Cancel/Save — keeping it on the one pill surface. The table
68
68
  * toolbar's `ColumnFilter` is this pill plus its query-condition mapping.
69
69
  */
70
- export function FilterPill(props: FilterPillProps) {
70
+ export function FilterChip(props: FilterChipProps) {
71
71
  const { label, summary, onClear, clearLabel = "Clear", children, side = "bottom", align = "start", open, onOpenChange, footer } = props;
72
72
  const active = summary != null && summary.length > 0;
73
73
  // The clear × / Clear footer only when there's a clearable selection AND no
@@ -87,12 +87,12 @@ export function FilterPill(props: FilterPillProps) {
87
87
  return (
88
88
  <Popover side={side} align={align} open={isOpen} onOpenChange={setOpen}>
89
89
  <PopoverTrigger>
90
- <PillButton onDismiss={showClear ? onClear : undefined} dismissTooltip={clearLabel}>
90
+ <Chip onDismiss={showClear ? onClear : undefined} dismissTooltip={clearLabel}>
91
91
  <Text size="sm" weight="medium" color={active ? "zinc-900" : "zinc-700"} numberOfLines={1}>
92
92
  {active ? `${label}: ${summary}` : label}
93
93
  </Text>
94
94
  {!showClear ? <Icon name="chevron-down" size={14} color={colors.zinc["400"]} /> : null}
95
- </PillButton>
95
+ </Chip>
96
96
  </PopoverTrigger>
97
97
  <PopoverContent style={styles.body} disableBodyScroll>
98
98
  {typeof children === "function" ? children({ close: () => setOpen(false) }) : children}
package/src/finding.tsx CHANGED
@@ -81,7 +81,7 @@ export function Finding(props: FindingProps) {
81
81
  ) : null}
82
82
  {props.action ? (
83
83
  <View style={styles.actionRow}>
84
- <Button title={props.action.label} color="secondary" shape="rounded" onPress={props.action.onPress} />
84
+ <Button title={props.action.label} color="secondary" onPress={props.action.onPress} />
85
85
  </View>
86
86
  ) : null}
87
87
  </View>