@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,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,38 @@
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";
18
+ import { FOCUS_RING } from "./control_surface";
19
+ import { useFocusRing } from "./use_focus_ring";
16
20
 
17
21
  export interface ThumbnailGridProps<T> {
18
22
  items: T[];
19
23
  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. */
24
+ /** Render one item at the computed tile size (px) always provided. */
22
25
  renderItem: (item: T, size?: number) => React.ReactNode;
23
- /** Fixed pixel size per item flat horizontal wrap. Takes precedence. */
26
+ /** EXACT tile size (px): tiles are exactly this, flat wrap. May leave space at
27
+ * the row's right edge. Use for fixed small tiles; omit to fill the width. */
24
28
  itemSize?: number;
25
- /** Fixed column count items fill 1/N of the row width. */
29
+ /** FILL with a fixed column count: tiles take an equal 1/N share of the width
30
+ * (so full rows span the container). Omit `itemSize` to use. */
26
31
  columns?: number;
27
- /** Minimum item width for auto column count when neither itemSize nor columns
28
- * is set. Default 96. */
32
+ /** FILL target: when neither `itemSize` nor `columns` is set, the column count
33
+ * is derived so each tile is ≥ this width and rows span the container. Default 96. */
29
34
  minItemWidth?: number;
30
35
  /** Gap between items. Default 8. */
31
36
  gap?: number;
32
- /** Max items shown before a "+N" overflow indicator. */
37
+ /** Max items shown before a "+N" overflow indicator (wrap mode). */
33
38
  maxVisible?: number;
39
+ /** SINGLE non-wrapping row that fits as many tiles as the measured width allows,
40
+ * reserving the last slot for a "+N" overflow tile. Sizes tiles by `itemSize`
41
+ * (else `minItemWidth`). The responsive overflow pattern — never wraps. */
42
+ singleRow?: boolean;
43
+ /** Press handler for the "+N" overflow tile (receives the hidden count). When
44
+ * omitted the tile is a passive indicator. */
45
+ onOverflowPress?: (hiddenCount: number) => void;
34
46
  /** Alignment of a partial last row. Default "start". */
35
47
  partialRowAlign?: "start" | "end";
36
48
  style?: ViewStyle;
@@ -50,6 +62,8 @@ export function ThumbnailGrid<T>(props: ThumbnailGridProps<T>) {
50
62
  minItemWidth = THUMBNAIL_SIZE,
51
63
  gap = 8,
52
64
  maxVisible,
65
+ singleRow,
66
+ onOverflowPress,
53
67
  partialRowAlign = "start",
54
68
  style,
55
69
  disablePress,
@@ -58,57 +72,79 @@ export function ThumbnailGrid<T>(props: ThumbnailGridProps<T>) {
58
72
 
59
73
  const [containerWidth, setContainerWidth] = useState(0);
60
74
 
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
75
  if (items.length === 0) return null;
73
76
 
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)}
77
+ // SINGLE-ROW fit-to-width: one non-wrapping row. Fit as many fixed-size tiles as
78
+ // the measured width allows, reserving the last slot for a "+N" overflow tile —
79
+ // the responsive overflow pattern (avatar stack / Finder / Figma). Needs a
80
+ // measured width pass.
81
+ if (singleRow) {
82
+ const size = itemSize ?? minItemWidth;
83
+ const fit = containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + gap) / (size + gap))) : 0;
84
+ const hasOverflow = fit > 0 && items.length > fit;
85
+ const visibleCount = hasOverflow ? Math.max(1, fit - 1) : items.length;
86
+ const hidden = items.length - visibleCount;
87
+ return (
88
+ <View
89
+ style={[styles.singleRow, { gap }, style]}
90
+ pointerEvents={disablePress ? "none" : "auto"}
91
+ onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
92
+ >
93
+ {fit > 0 &&
94
+ items.slice(0, visibleCount).map((item) => (
95
+ <View key={keyExtractor(item)} style={{ width: size }}>
96
+ {renderItem(item, size)}
90
97
  </View>
91
98
  ))}
92
- {partialRowAlign === "start" && spacers}
93
- </View>
94
- );
95
- });
96
- };
99
+ {fit > 0 && hidden > 0 && (
100
+ <OverflowTile
101
+ size={size}
102
+ count={hidden}
103
+ onPress={onOverflowPress ? () => onOverflowPress(hidden) : undefined}
104
+ />
105
+ )}
106
+ {footer}
107
+ </View>
108
+ );
109
+ }
110
+
111
+ // Two sizing models, both a flat wrap:
112
+ // • Exact (`itemSize`): tiles are exactly that size — for fixed small tiles
113
+ // (badges). The row may not reach the container's right edge.
114
+ // • Fill (default, or `columns`): the column count comes from `columns` or is
115
+ // derived from `minItemWidth`, then every tile takes an EQUAL share of the
116
+ // width so full rows span the container exactly. A short last row keeps that
117
+ // same tile size and is left-/right-aligned — no stretched tiles, no empty
118
+ // spacer cells. Needs one measured layout pass before it can render.
119
+ const isExact = itemSize !== undefined;
120
+ const autoColumns =
121
+ containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + gap) / (minItemWidth + gap))) : 0;
122
+ const cols = columns ?? autoColumns;
123
+ const fillSize =
124
+ cols > 0 && containerWidth > 0 ? Math.floor((containerWidth - (cols - 1) * gap) / cols) : 0;
125
+
126
+ const tileSize = isExact ? itemSize : fillSize;
127
+ const canRender = tileSize > 0;
128
+
129
+ const visible = maxVisible ? items.slice(0, maxVisible) : items;
130
+ const overflow = maxVisible ? Math.max(0, items.length - maxVisible) : 0;
97
131
 
98
132
  return (
99
133
  <View
100
- style={[isCompact && styles.compactContainer, { gap }, style]}
134
+ style={[
135
+ styles.container,
136
+ { gap, justifyContent: partialRowAlign === "end" ? "flex-end" : "flex-start" },
137
+ style,
138
+ ]}
101
139
  pointerEvents={disablePress ? "none" : "auto"}
102
140
  onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
103
141
  >
104
142
  {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)}
143
+ visible.map((item) => (
144
+ <View key={keyExtractor(item)} style={{ width: tileSize }}>
145
+ {renderItem(item, tileSize)}
146
+ </View>
147
+ ))}
112
148
 
113
149
  {overflow > 0 && (
114
150
  <Text size="sm" color="muted" userSelect="none">
@@ -128,6 +164,8 @@ export interface FileThumbnailGridProps {
128
164
  minItemWidth?: number;
129
165
  gap?: number;
130
166
  maxVisible?: number;
167
+ singleRow?: boolean;
168
+ onOverflowPress?: (hiddenCount: number) => void;
131
169
  partialRowAlign?: "start" | "end";
132
170
  style?: ViewStyle;
133
171
  disablePress?: boolean;
@@ -158,8 +196,50 @@ export function FileThumbnailGrid(props: FileThumbnailGridProps) {
158
196
  );
159
197
  }
160
198
 
199
+ /** The "+N" overflow cell: a same-size tile showing the hidden count. Clickable
200
+ * when `onPress` is given (the host opens a gallery / expands), else passive. */
201
+ function OverflowTile({ size, count, onPress }: { size: number; count: number; onPress?: () => void }) {
202
+ const compact = size <= COMPACT_THUMBNAIL_SIZE;
203
+ const box = { width: size, height: size, borderRadius: compact ? 7 : 10 };
204
+ const { focusVisible, focusProps } = useFocusRing();
205
+ const label = (
206
+ <Text size={compact ? "xs" : "sm"} weight="medium" color="zinc-700" userSelect="none">
207
+ +{count}
208
+ </Text>
209
+ );
210
+ if (!onPress) {
211
+ return <View style={[styles.overflowTile, box]}>{label}</View>;
212
+ }
213
+ return (
214
+ <Pressable
215
+ onPress={onPress}
216
+ {...focusProps}
217
+ accessibilityRole="button"
218
+ accessibilityLabel={`+${count}`}
219
+ style={(state) => [
220
+ styles.overflowTile,
221
+ box,
222
+ (state as { hovered?: boolean }).hovered && styles.overflowHovered,
223
+ state.pressed && styles.overflowPressed,
224
+ focusVisible && { boxShadow: FOCUS_RING },
225
+ ]}
226
+ >
227
+ {label}
228
+ </Pressable>
229
+ );
230
+ }
231
+
161
232
  const styles = StyleSheet.create({
162
- compactContainer: { flexDirection: "row", flexWrap: "wrap", userSelect: "none" },
163
- row: { flexDirection: "row" },
164
- flexItem: { flex: 1 },
233
+ container: { flexDirection: "row", flexWrap: "wrap", userSelect: "none" },
234
+ singleRow: { flexDirection: "row", alignItems: "center", userSelect: "none" },
235
+ overflowTile: {
236
+ backgroundColor: colors.zinc["100"],
237
+ borderWidth: 1,
238
+ borderColor: colors.zinc["200"],
239
+ alignItems: "center",
240
+ justifyContent: "center",
241
+ ...({ transitionDuration: "0.1s", transitionProperty: "background-color" } as object),
242
+ },
243
+ overflowHovered: { backgroundColor: colors.zinc["200"] },
244
+ overflowPressed: { backgroundColor: colors.zinc["300"] },
165
245
  });
@@ -0,0 +1,232 @@
1
+ import { useState } from "react";
2
+ import { View, StyleSheet } from "react-native";
3
+ import { Button } from "./button";
4
+ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
5
+ import { MenuButton } from "./menu_button";
6
+ import { Alert } from "./alert";
7
+ import { FileGrid, type FileUpload } from "./file_grid";
8
+ import { FileGalleryModal } from "./file_gallery_modal";
9
+ import type { GalleryLabels } from "./file_preview_types";
10
+ import { type DisplayFile } from "./file_thumbnail";
11
+ import { pickFiles } from "./file_picker";
12
+ import { downloadFileFromUrl } from "./download";
13
+
14
+ export interface FilesEditorLabels {
15
+ upload: string;
16
+ select: string;
17
+ selectAll: string;
18
+ deselectAll: string;
19
+ done: string;
20
+ /** The select-mode actions menu trigger. */
21
+ menu: string;
22
+ downloadAll: string;
23
+ /** Download-selected menu item, given the count. */
24
+ downloadSelected: (n: number) => string;
25
+ /** Download-as-ZIP menu item (shown when `onDownloadZipSelected` + 2+ selected). */
26
+ downloadZip: string;
27
+ share: string;
28
+ delete: string;
29
+ removeTitle: string;
30
+ removeMessage: string;
31
+ removeCancel: string;
32
+ removeConfirm: string;
33
+ }
34
+
35
+ const DEFAULT_LABELS: FilesEditorLabels = {
36
+ upload: "Upload",
37
+ select: "Select",
38
+ selectAll: "Select all",
39
+ deselectAll: "Deselect all",
40
+ done: "Done",
41
+ menu: "Menu",
42
+ downloadAll: "Download all",
43
+ downloadSelected: (n) => `Download (${n})`,
44
+ downloadZip: "Download as ZIP",
45
+ share: "Share",
46
+ delete: "Delete",
47
+ removeTitle: "Remove attachment?",
48
+ removeMessage: "This removes the file.",
49
+ removeCancel: "Cancel",
50
+ removeConfirm: "Remove",
51
+ };
52
+
53
+ export interface FilesEditorProps {
54
+ /** Completed/saved files. */
55
+ files: DisplayFile[];
56
+ /** The live add-queue (in-flight uploads) — pass through from your upload mechanism. */
57
+ uploads?: FileUpload[];
58
+ /** Picked files → the host uploads + appends to `files`. Omit to hide Upload. */
59
+ onAdd?: (files: File[]) => void;
60
+ /** Override the Upload action's picker (e.g. a native document picker). When
61
+ * set, the Upload button calls this instead of the built-in web `pickFiles`. */
62
+ onUpload?: () => void;
63
+ /** Show a "Download as ZIP" action in select mode (2+ selected), given the files. */
64
+ onDownloadZipSelected?: (files: DisplayFile[]) => void;
65
+ /** Remove a completed file. Omit (or `readOnly`) for a view-only surface. */
66
+ onRemove?: (id: string) => void;
67
+ /** Cancel an in-flight upload / retry a failed one (when using `uploads`). */
68
+ onUploadRemove?: (id: string) => void;
69
+ onRetry?: (id: string) => void;
70
+ onRetryAll?: () => void;
71
+ /** Show a Share action in select mode, given the selected files. Omit to hide it. */
72
+ onShareSelected?: (files: DisplayFile[]) => void;
73
+ /** Override the default download (`downloadFileFromUrl`). */
74
+ onDownload?: (file: DisplayFile) => void;
75
+ /** Download + preview only (no Upload / Select / remove). */
76
+ readOnly?: boolean;
77
+ /** Native `accept` filter for the file picker. */
78
+ accept?: string;
79
+ itemSize?: number;
80
+ minItemWidth?: number;
81
+ /** Fixed column count for the grid (passed through to `FileGrid`). */
82
+ columns?: number;
83
+ /** Translated labels. Defaults to English. */
84
+ labels?: Partial<FilesEditorLabels>;
85
+ /** Credentials mode for the built-in gallery's preview fetches — `"include"`
86
+ * for the host's auth-gated proxy URLs, omitted for an app's presigned URLs.
87
+ * See `FilePreviewProps`. */
88
+ credentials?: RequestCredentials;
89
+ /** Translated labels for the built-in full-screen gallery (its toolbar action
90
+ * buttons + confirm dialog). Defaults to English. */
91
+ galleryLabels?: Partial<GalleryLabels>;
92
+ }
93
+
94
+ /**
95
+ * The all-in-one attachment surface: an upload-aware thumbnail grid (`FileGrid`)
96
+ * with a toolbar BELOW it, a batch SELECT mode, full-screen preview
97
+ * (`FileGalleryModal`), and download / share / Alert-confirmed remove. Agnostic —
98
+ * the host wires `onAdd` (picked files → its upload) and `onRemove`; select mode,
99
+ * the gallery, download and the remove confirm are all internal. Mirrors the
100
+ * frontend `files_editor`. For a plain row list use `FileRows`; for the bare
101
+ * upload grid use `FileGrid`.
102
+ */
103
+ export function FilesEditor(props: FilesEditorProps) {
104
+ const {
105
+ files,
106
+ uploads,
107
+ onAdd,
108
+ onUpload,
109
+ onDownloadZipSelected,
110
+ onRemove,
111
+ onUploadRemove,
112
+ onRetry,
113
+ onRetryAll,
114
+ onShareSelected,
115
+ onDownload,
116
+ readOnly = false,
117
+ accept,
118
+ itemSize,
119
+ minItemWidth,
120
+ columns,
121
+ labels,
122
+ credentials,
123
+ galleryLabels,
124
+ } = props;
125
+ const l = { ...DEFAULT_LABELS, ...labels };
126
+
127
+ const [selectMode, setSelectMode] = useState(false);
128
+ const [selected, setSelected] = useState<Set<string>>(() => new Set());
129
+ const [menuOpen, setMenuOpen] = useState(false);
130
+ const [galleryIndex, setGalleryIndex] = useState<number | null>(null);
131
+
132
+ const hasFiles = files.length > 0;
133
+ const allSelected = hasFiles && selected.size === files.length;
134
+ const selectedFiles = files.filter((f) => selected.has(f.id));
135
+
136
+ const toggle = (id: string) =>
137
+ setSelected((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
138
+ const exitSelect = () => { setSelectMode(false); setSelected(new Set()); };
139
+ const download = (fs: DisplayFile[]) =>
140
+ fs.forEach((f) => (onDownload ? onDownload(f) : void downloadFileFromUrl(f.url, f.filename, { credentials })));
141
+ const confirmRemove = (onConfirm: () => void) =>
142
+ Alert.alert(l.removeTitle, l.removeMessage, [
143
+ { text: l.removeCancel, style: "cancel" },
144
+ { text: l.removeConfirm, style: "destructive", onPress: onConfirm },
145
+ ]);
146
+ const upload = () => {
147
+ if (!onAdd) return;
148
+ void pickFiles({ multiple: true, accept }).then((picked) => { if (picked.length > 0) onAdd(picked); });
149
+ };
150
+
151
+ return (
152
+ <View style={styles.root}>
153
+ <FileGrid
154
+ files={files}
155
+ uploads={uploads}
156
+ itemSize={itemSize}
157
+ minItemWidth={minItemWidth}
158
+ columns={columns}
159
+ selectedIds={selectMode ? selected : undefined}
160
+ onToggleSelect={selectMode ? toggle : undefined}
161
+ onFilePress={selectMode ? undefined : (f) => setGalleryIndex(files.findIndex((x) => x.id === f.id))}
162
+ onDisplayRemove={!readOnly && !selectMode && onRemove ? (id) => confirmRemove(() => onRemove(id)) : undefined}
163
+ onUploadRemove={onUploadRemove}
164
+ onRetry={onRetry}
165
+ onRetryAll={onRetryAll}
166
+ />
167
+
168
+ {readOnly ? (
169
+ hasFiles ? (
170
+ <View style={styles.bar}>
171
+ <View style={styles.spacer} />
172
+ <Button title={l.downloadAll} color="secondary" onPress={() => download(files)} />
173
+ </View>
174
+ ) : null
175
+ ) : selectMode ? (
176
+ <View style={styles.bar}>
177
+ <Button
178
+ title={allSelected ? l.deselectAll : l.selectAll}
179
+ color="muted"
180
+ onPress={() => setSelected(allSelected ? new Set() : new Set(files.map((f) => f.id)))}
181
+ />
182
+ <Popover open={menuOpen} onOpenChange={setMenuOpen} align="start">
183
+ <PopoverTrigger>
184
+ <Button title={l.menu} color="secondary" disabled={selected.size === 0} onPress={() => setMenuOpen(true)} />
185
+ </PopoverTrigger>
186
+ <PopoverContent>
187
+ <View style={styles.menu}>
188
+ <MenuButton icon="download" title={l.downloadSelected(selected.size)} onPress={() => { setMenuOpen(false); download(selectedFiles); }} />
189
+ {onDownloadZipSelected && selected.size > 1 ? (
190
+ <MenuButton icon="file-down" title={l.downloadZip} onPress={() => { setMenuOpen(false); onDownloadZipSelected(selectedFiles); }} />
191
+ ) : null}
192
+ {onShareSelected ? (
193
+ <MenuButton icon="share" title={l.share} onPress={() => { setMenuOpen(false); onShareSelected(selectedFiles); }} />
194
+ ) : null}
195
+ {onRemove ? (
196
+ <MenuButton icon="trash" title={l.delete} danger onPress={() => { setMenuOpen(false); confirmRemove(() => { selectedFiles.forEach((f) => onRemove(f.id)); exitSelect(); }); }} />
197
+ ) : null}
198
+ </View>
199
+ </PopoverContent>
200
+ </Popover>
201
+ <View style={styles.spacer} />
202
+ <Button title={l.done} color="secondary" onPress={exitSelect} />
203
+ </View>
204
+ ) : (
205
+ <View style={styles.bar}>
206
+ {onAdd || onUpload ? <Button title={l.upload} color="primary" onPress={onUpload ?? upload} /> : null}
207
+ {hasFiles ? <Button title={l.select} color="secondary" onPress={() => setSelectMode(true)} /> : null}
208
+ <View style={styles.spacer} />
209
+ {hasFiles ? <Button title={l.downloadAll} color="secondary" onPress={() => download(files)} /> : null}
210
+ </View>
211
+ )}
212
+
213
+ <FileGalleryModal
214
+ files={files}
215
+ activeIndex={galleryIndex}
216
+ onIndexChange={setGalleryIndex}
217
+ onRemove={!readOnly && onRemove ? onRemove : undefined}
218
+ onDownload={onDownload}
219
+ onOpenExternal={(f) => { if (typeof window !== "undefined") window.open(f.url, "_blank"); }}
220
+ credentials={credentials}
221
+ labels={galleryLabels}
222
+ />
223
+ </View>
224
+ );
225
+ }
226
+
227
+ const styles = StyleSheet.create({
228
+ root: { gap: 10 },
229
+ bar: { flexDirection: "row", alignItems: "center", gap: 8 },
230
+ spacer: { flex: 1 },
231
+ menu: { minWidth: 200, gap: 2, padding: 4 },
232
+ });
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode, useState } from "react";
2
- import { StyleSheet } from "react-native";
2
+ import { StyleSheet, View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Icon } from "./icon";
5
5
  import { colors } from "./colors";
@@ -12,20 +12,21 @@ 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;
15
- /** A short human summary of the active selection ("Maria, James", "≥ 10").
16
- * Empty / undefined renders the inactive pill (label + chevron, no clear).
17
- * Build it with `rangeSummary` / `selectSummary` for a consistent preview. */
18
- summary?: string;
15
+ /** The active selection preview, after "Label: ". A string ("Maria, James",
16
+ * "≥ 10" build with `rangeSummary` / `selectSummary`), OR a node to render the
17
+ * selection RICHLY in the trigger (member avatars, colour dots). Empty /
18
+ * undefined renders the inactive pill (label + chevron, no clear). */
19
+ summary?: ReactNode;
19
20
  /** Clears the dimension — renders the × on the pill AND a Clear in the popover
20
21
  * footer whenever a summary is present. */
21
22
  onClear?: () => void;
22
23
  /** Label for the clear affordances (pass a translated string). */
23
24
  clearLabel?: string;
24
25
  /** The editor revealed on press — a premium primitive (`RangeSlider`,
25
- * `Counter`, `PickerMenu` multi) or any composed control. `FilterChip` owns
26
+ * `Counter`, `OptionList` multi) or any composed control. `FilterChip` owns
26
27
  * the pill + popover chrome; the consumer owns the value and applies it.
27
28
  * Pass a render function to receive `close` — the one editor that closes on
28
- * action is single-select (`<PickerMenu>` → close on pick); range/counter/
29
+ * action is single-select (`<OptionList>` → close on pick); range/counter/
29
30
  * multi stay open and dismiss on outside-press, so they pass a plain node. */
30
31
  children: ReactNode | ((api: { close: () => void }) => ReactNode);
31
32
  side?: PopoverSide;
@@ -62,14 +63,14 @@ export function selectSummary(
62
63
  * × to clear. `FilterChip` bakes the consistent chrome — the preview pill, a
63
64
  * padded popover body for the composed editor, and a Clear footer when active —
64
65
  * so every filter looks and behaves the same. Drop a premium primitive inside
65
- * (`RangeSlider`, `Counter`, `PickerMenu` multi). With `footer` (+ controlled
66
+ * (`RangeSlider`, `Counter`, `OptionList` multi). With `footer` (+ controlled
66
67
  * `open`/`onOpenChange`) the same shell wraps a non-filter VALUE pill — a
67
68
  * setting gate with Cancel/Save — keeping it on the one pill surface. The table
68
69
  * toolbar's `ColumnFilter` is this pill plus its query-condition mapping.
69
70
  */
70
71
  export function FilterChip(props: FilterChipProps) {
71
72
  const { label, summary, onClear, clearLabel = "Clear", children, side = "bottom", align = "start", open, onOpenChange, footer } = props;
72
- const active = summary != null && summary.length > 0;
73
+ const active = summary != null && (typeof summary !== "string" || summary.length > 0);
73
74
  // The clear × / Clear footer only when there's a clearable selection AND no
74
75
  // custom footer — a valued, non-clearable pill ("Target: 20") keeps its chevron.
75
76
  const showClear = active && !!onClear && !footer;
@@ -88,9 +89,16 @@ export function FilterChip(props: FilterChipProps) {
88
89
  <Popover side={side} align={align} open={isOpen} onOpenChange={setOpen}>
89
90
  <PopoverTrigger>
90
91
  <Chip onDismiss={showClear ? onClear : undefined} dismissTooltip={clearLabel}>
91
- <Text size="sm" weight="medium" color={active ? "zinc-900" : "zinc-700"} numberOfLines={1}>
92
- {active ? `${label}: ${summary}` : label}
93
- </Text>
92
+ {active && typeof summary !== "string" ? (
93
+ <View style={styles.summaryRow}>
94
+ <Text size="sm" weight="medium" color="zinc-700">{`${label}:`}</Text>
95
+ {summary}
96
+ </View>
97
+ ) : (
98
+ <Text size="sm" weight="medium" color={active ? "zinc-900" : "zinc-700"} numberOfLines={1}>
99
+ {active ? `${label}: ${summary}` : label}
100
+ </Text>
101
+ )}
94
102
  {!showClear ? <Icon name="chevron-down" size={14} color={colors.zinc["400"]} /> : null}
95
103
  </Chip>
96
104
  </PopoverTrigger>
@@ -116,4 +124,9 @@ const styles = StyleSheet.create({
116
124
  body: {
117
125
  gap: 8,
118
126
  },
127
+ summaryRow: {
128
+ flexDirection: "row",
129
+ alignItems: "center",
130
+ gap: 5,
131
+ },
119
132
  });
@@ -0,0 +1,34 @@
1
+ import { Pressable, type PressableProps, type StyleProp, type View, type ViewStyle } from "react-native";
2
+ import { type Ref } from "react";
3
+ import { FOCUS_RING } from "./control_surface";
4
+ import { useFocusRing, composeHandler } from "./use_focus_ring";
5
+
6
+ export interface FocusRingPressableProps extends PressableProps {
7
+ ref?: Ref<View>;
8
+ }
9
+
10
+ /**
11
+ * A `Pressable` that paints the kit `FOCUS_RING` on keyboard focus — the raw-control
12
+ * counterpart to `PressableHighlight`'s `focusRing` prop, for interactive elements
13
+ * that DON'T want the hover wash (cells, tiles, nav buttons, menu options). Forwards
14
+ * every `PressableProps` (onPress, accessibilityRole/Label/State, ref, …) and merges
15
+ * the ring with the caller's `style` (object or state-function) + onFocus/onBlur.
16
+ * Reach for this before hand-rolling a one-off Pressable + `useFocusRing`.
17
+ */
18
+ export function FocusRingPressable({ ref, style, onFocus, onBlur, ...rest }: FocusRingPressableProps) {
19
+ const { focusVisible, focusProps } = useFocusRing();
20
+ return (
21
+ <Pressable
22
+ ref={ref}
23
+ {...rest}
24
+ onFocus={composeHandler(onFocus ?? undefined, focusProps.onFocus)}
25
+ onBlur={composeHandler(onBlur ?? undefined, focusProps.onBlur)}
26
+ style={(state) =>
27
+ [
28
+ typeof style === "function" ? style(state) : style,
29
+ focusVisible ? { boxShadow: FOCUS_RING } : null,
30
+ ] as StyleProp<ViewStyle>
31
+ }
32
+ />
33
+ );
34
+ }
@@ -1,9 +1,9 @@
1
1
  import { FormField, FormFieldProps } from "./form_field";
2
2
  import { Picker, PickerProps } from "./picker";
3
3
 
4
- /** `FormField` wrapping a `Picker` — one labeled select. */
5
- export function FormPicker<T extends string, MULTI extends boolean = false>(
6
- props: PickerProps<T, MULTI> & FormFieldProps,
4
+ /** `FormField` wrapping a `Picker` — one labeled native select. */
5
+ export function FormPicker<T extends string>(
6
+ props: PickerProps<T> & FormFieldProps,
7
7
  ) {
8
8
  const { label, description, optional, optionalLabel, error, ...pickerProps } = props;
9
9