@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.
- package/AGENTS.md +184 -56
- package/examples/tpl_approvals.tsx +3 -3
- package/examples/tpl_convert.tsx +11 -23
- package/examples/tpl_dossier.tsx +5 -4
- package/examples/tpl_order.tsx +1 -1
- package/examples/tpl_pipeline.tsx +400 -0
- package/examples/tpl_ratedesk.tsx +1 -1
- package/examples/tpl_record.tsx +6 -5
- package/examples/tpl_shifts.tsx +7 -5
- package/examples/tpl_task_board.tsx +473 -0
- package/examples/tpl_tasks.tsx +440 -0
- package/package.json +19 -6
- package/src/accordion.tsx +1 -1
- package/src/agent_progress.tsx +1 -1
- package/src/agent_run.tsx +1 -1
- package/src/breakdown.tsx +2 -2
- package/src/button.tsx +12 -3
- package/src/calendar/calendar_view.tsx +10 -10
- package/src/calendar/month_view.tsx +30 -11
- package/src/calendar/time_grid_view.tsx +25 -7
- package/src/card_select_item.tsx +11 -13
- package/src/check_circle.tsx +88 -0
- package/src/checkbox_input.tsx +5 -1
- package/src/chip.tsx +1 -1
- package/src/chip_group.tsx +1 -1
- package/src/column_filter.tsx +3 -2
- package/src/combobox.tsx +84 -219
- package/src/confidence.tsx +14 -3
- package/src/control_surface.ts +22 -7
- package/src/data_grid.tsx +159 -0
- package/src/date_calendar.tsx +46 -23
- package/src/date_field.tsx +10 -2
- package/src/date_filter.tsx +5 -9
- package/src/date_picker.tsx +78 -26
- package/src/date_picker_value.ts +11 -0
- package/src/date_range_filter_field.tsx +6 -4
- package/src/drawer.tsx +1 -1
- package/src/file_dropzone.tsx +5 -0
- package/src/file_gallery_modal.tsx +227 -139
- package/src/file_grid.tsx +184 -0
- package/src/file_preview.web.tsx +349 -12
- package/src/file_preview_types.ts +49 -0
- package/src/file_row.tsx +118 -0
- package/src/file_rows.tsx +109 -0
- package/src/file_thumbnail.tsx +132 -81
- package/src/file_thumbnail_grid.tsx +135 -55
- package/src/files_editor.tsx +232 -0
- package/src/filter_chip.tsx +25 -12
- package/src/focus_ring_pressable.tsx +34 -0
- package/src/form_picker.tsx +3 -3
- package/src/gantt/gantt_view.tsx +25 -7
- package/src/heatmap.tsx +5 -1
- package/src/icon_button.tsx +14 -3
- package/src/image_gallery.tsx +8 -14
- package/src/index.css +8 -12
- package/src/inline_date_picker.tsx +11 -11
- package/src/inline_edit.tsx +22 -6
- package/src/inline_member_select.tsx +1 -1
- package/src/inline_select.tsx +9 -8
- package/src/inline_text_input.tsx +4 -1
- package/src/link.tsx +11 -1
- package/src/link_button.tsx +1 -1
- package/src/list_item.tsx +2 -2
- package/src/member_select.tsx +9 -4
- package/src/menu_button.tsx +2 -2
- package/src/menu_list_item.tsx +2 -2
- package/src/number_input.tsx +11 -4
- package/src/option_list.tsx +211 -0
- package/src/pdfjs_worker.d.ts +5 -0
- package/src/peek.tsx +1 -1
- package/src/picker.tsx +24 -213
- package/src/pressable_highlight.tsx +42 -23
- package/src/radio_picker.tsx +2 -2
- package/src/range_slider.tsx +35 -7
- package/src/react_native.d.ts +3 -0
- package/src/scroll_to_bottom.tsx +1 -1
- package/src/section.tsx +7 -2
- package/src/segmented_control.tsx +2 -2
- package/src/select.tsx +299 -0
- package/src/sort_header.tsx +1 -1
- package/src/sources.tsx +1 -1
- package/src/spreadsheet_view.tsx +43 -17
- package/src/status_grid.tsx +4 -3
- package/src/stepper.tsx +1 -1
- package/src/switch.tsx +5 -1
- package/src/switch_button.tsx +1 -1
- package/src/switcher.tsx +1 -1
- package/src/table.tsx +5 -1
- package/src/tabs.tsx +2 -2
- package/src/text_input_field.tsx +15 -4
- package/src/time_picker.tsx +11 -4
- package/src/timeline.tsx +1 -1
- package/src/uploading_thumbnail.tsx +212 -0
- package/src/use_focus_ring.ts +80 -0
- package/src/use_hover.ts +26 -0
- package/src/use_list_keyboard_nav.test.ts +71 -0
- package/src/use_list_keyboard_nav.ts +52 -5
- package/src/use_option_list.test.ts +193 -0
- package/src/use_option_list.ts +354 -0
- package/src/command_menu.tsx +0 -205
- package/src/hover_action.tsx +0 -54
- package/src/picker_menu.tsx +0 -355
- package/src/tag_input.tsx +0 -203
- 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
|
|
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,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 {
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
28
|
-
* is
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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={[
|
|
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
|
-
(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
});
|
package/src/filter_chip.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
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`, `
|
|
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 (`<
|
|
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`, `
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
}
|
package/src/form_picker.tsx
CHANGED
|
@@ -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
|
|
6
|
-
props: PickerProps<T
|
|
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
|
|