@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.
- package/AGENTS.md +102 -42
- package/examples/tpl_approvals.tsx +10 -10
- package/examples/tpl_convert.tsx +11 -23
- package/examples/tpl_crm_desk.tsx +3 -3
- package/examples/tpl_dieline.tsx +7 -6
- package/examples/tpl_directory.tsx +3 -3
- package/examples/tpl_dossier.tsx +7 -7
- package/examples/tpl_draft.tsx +4 -4
- package/examples/tpl_extract.tsx +7 -7
- package/examples/tpl_inventory.tsx +3 -3
- package/examples/tpl_lookup.tsx +4 -3
- package/examples/tpl_match.tsx +1 -1
- package/examples/tpl_order.tsx +1 -1
- package/examples/tpl_ratedesk.tsx +12 -12
- package/examples/tpl_record.tsx +1 -1
- package/examples/tpl_report.tsx +3 -3
- package/examples/tpl_stock.tsx +3 -3
- package/examples/tpl_triage.tsx +1 -1
- package/package.json +12 -4
- package/src/agent_progress.tsx +14 -12
- package/src/agent_run.tsx +253 -66
- package/src/back_button.tsx +3 -2
- package/src/badge.tsx +2 -1
- package/src/button.tsx +11 -32
- package/src/button_colors.ts +23 -0
- package/src/calendar/calendar_view.tsx +2 -2
- package/src/card_select_item.tsx +2 -1
- package/src/change_review.tsx +5 -6
- package/src/{pill_button.tsx → chip.tsx} +7 -7
- package/src/chip_group.tsx +2 -2
- package/src/column_filter.tsx +4 -4
- package/src/combobox.tsx +2 -9
- package/src/composer.tsx +4 -3
- package/src/control_surface.ts +15 -15
- package/src/date_field.tsx +2 -1
- package/src/date_filter.tsx +2 -1
- package/src/date_range_filter_field.tsx +2 -1
- package/src/dialog.tsx +2 -2
- package/src/discrepancy.tsx +1 -1
- package/src/drawer.tsx +1 -2
- package/src/file_gallery_modal.tsx +220 -133
- package/src/file_grid.tsx +184 -0
- package/src/file_preview.web.tsx +329 -6
- package/src/file_preview_types.ts +40 -0
- package/src/file_row.tsx +114 -0
- package/src/file_rows.tsx +105 -0
- package/src/file_thumbnail.tsx +121 -79
- package/src/file_thumbnail_grid.tsx +130 -55
- package/src/{filter_pill.tsx → filter_chip.tsx} +8 -8
- package/src/finding.tsx +1 -1
- package/src/gantt/gantt_view.tsx +2 -2
- package/src/icon_button.tsx +49 -14
- package/src/image_gallery.tsx +0 -4
- package/src/inline_edit.tsx +2 -2
- package/src/link_button.tsx +2 -1
- package/src/menu_button.tsx +2 -1
- package/src/menu_list_item.tsx +2 -1
- package/src/number_input.tsx +2 -1
- package/src/pdfjs_worker.d.ts +5 -0
- package/src/picker.tsx +3 -3
- package/src/popover_header.tsx +2 -2
- package/src/radio_picker.tsx +2 -1
- package/src/range_slider.tsx +1 -1
- package/src/review_card.tsx +2 -2
- package/src/scan_field.tsx +2 -1
- package/src/scored_option.tsx +0 -1
- package/src/search_input.tsx +3 -2
- package/src/segmented_control.tsx +2 -1
- package/src/status_badge.tsx +2 -1
- package/src/stepper.tsx +3 -2
- package/src/switch_button.tsx +2 -1
- package/src/switcher.tsx +3 -2
- package/src/tabs.tsx +2 -1
- package/src/tag_input.tsx +2 -1
- package/src/text_input_field.tsx +2 -1
- package/src/time_field.tsx +2 -1
- package/src/time_picker.tsx +2 -1
- package/src/triage_row.tsx +3 -3
- package/src/uploading_thumbnail.tsx +212 -0
- package/src/hover_action.tsx +0 -54
package/src/file_thumbnail.tsx
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
<
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
<
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
{
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
349
|
-
<
|
|
350
|
-
</
|
|
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:
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
480
|
+
borderRadius: 10,
|
|
431
481
|
overflow: "hidden",
|
|
432
482
|
},
|
|
433
483
|
image: {
|
|
434
484
|
width: "100%",
|
|
435
485
|
height: "100%",
|
|
436
486
|
},
|
|
437
|
-
|
|
487
|
+
removeButtonAnchor: {
|
|
438
488
|
position: "absolute",
|
|
439
|
-
top: -
|
|
440
|
-
right: -
|
|
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:
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 {
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
28
|
-
* is
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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={[
|
|
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
|
-
(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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 {
|
|
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
|
|
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. `
|
|
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 `
|
|
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. `
|
|
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
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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"
|
|
84
|
+
<Button title={props.action.label} color="secondary" onPress={props.action.onPress} />
|
|
85
85
|
</View>
|
|
86
86
|
) : null}
|
|
87
87
|
</View>
|