@lotics/ui 4.9.0 → 5.1.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 +75 -34
- package/examples/tpl_assistant.tsx +25 -4
- package/examples/tpl_dieline.tsx +2 -2
- package/examples/tpl_extract.tsx +18 -10
- package/examples/tpl_lookup.tsx +80 -67
- package/examples/tpl_match.tsx +25 -14
- package/examples/tpl_ratedesk.tsx +375 -0
- package/package.json +4 -4
- package/src/agent_run.tsx +1 -1
- package/src/change_review.tsx +103 -96
- package/src/combobox.tsx +8 -1
- package/src/confidence.tsx +1 -1
- package/src/control_surface.ts +27 -1
- package/src/file_thumbnail.tsx +32 -1
- package/src/icon.tsx +2 -0
- package/src/match_sides.tsx +79 -0
- package/src/record_fields.tsx +68 -0
- package/src/review_card.tsx +172 -0
- package/src/match_row.tsx +0 -133
- package/src/record_review.tsx +0 -149
- package/src/suggestion.tsx +0 -92
package/src/change_review.tsx
CHANGED
|
@@ -1,33 +1,38 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
1
2
|
import { StyleSheet, View } from "react-native";
|
|
2
3
|
import { colors } from "./colors";
|
|
4
|
+
import { reviewCardStyle, reviewResolvedStyle } from "./control_surface";
|
|
3
5
|
import { Text } from "./text";
|
|
4
6
|
import { Icon } from "./icon";
|
|
5
7
|
import { Button } from "./button";
|
|
6
8
|
|
|
7
9
|
export type ChangeReviewItemStatus = "pending" | "accepted" | "rejected";
|
|
8
10
|
|
|
9
|
-
export interface
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
11
|
+
export interface ChangeReviewProps<T> {
|
|
12
|
+
/** The proposed changes to review. Each renders however the host wants via
|
|
13
|
+
* `renderItem` — a field diff (`ChangeDiff`), a record card, an editable
|
|
14
|
+
* data-entry form. The component owns the review MECHANICS, not the look. */
|
|
15
|
+
items: T[];
|
|
16
|
+
/** Stable identity per item. */
|
|
17
|
+
getKey: (item: T, index: number) => string;
|
|
18
|
+
/** The PENDING proposal body — render it however fits. The host owns any edit
|
|
19
|
+
* state inside it (e.g. an editable price), so a "kept" item carries the
|
|
20
|
+
* host's edits when applied. */
|
|
21
|
+
renderItem: (item: T, index: number) => ReactNode;
|
|
22
|
+
/** The collapsed one-line preview shown AFTER a decision (1-by-1 mode). The
|
|
23
|
+
* component frames it with the Kept/Dropped tag + Undo; this fills the row.
|
|
24
|
+
* Omit to fall back to `getTitle` (struck when dropped). */
|
|
25
|
+
renderSummary?: (item: T, accepted: boolean, index: number) => ReactNode;
|
|
26
|
+
/** Short title for the default collapsed row when `renderSummary` is omitted. */
|
|
27
|
+
getTitle?: (item: T, index: number) => string;
|
|
28
|
+
/** Per-item decision, controlled by the host (default "pending"). Drives the
|
|
29
|
+
* collapse + the "N of M kept" counter in 1-by-1 mode. */
|
|
30
|
+
statusOf?: (item: T, index: number) => ChangeReviewItemStatus;
|
|
31
|
+
/** The instruction that produced the changes ("make it 5 mm taller"). */
|
|
24
32
|
summary?: string;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
/** Once resolved the card settles to a quiet outcome line (no actions). */
|
|
29
|
-
status?: "open" | "applied" | "discarded";
|
|
30
|
-
/** Providing either flips the card into 1-BY-1 mode: each edit is its own
|
|
33
|
+
/** Card heading. Default "Suggested edits". */
|
|
34
|
+
title?: string;
|
|
35
|
+
/** Providing either flips the card into 1-BY-1 mode: each proposal is its own
|
|
31
36
|
* card with Keep / Drop; deciding COLLAPSES it to a preview the host can
|
|
32
37
|
* `onUndoItem`. "Accept all" + "Apply kept" sit in a separate footer. The
|
|
33
38
|
* decision is recorded — nothing writes until Apply. Omit both for whole-set. */
|
|
@@ -35,11 +40,22 @@ export interface ChangeReviewProps {
|
|
|
35
40
|
onRejectItem?: (index: number) => void;
|
|
36
41
|
/** Revert a decided item back to pending (the Undo on a collapsed card). */
|
|
37
42
|
onUndoItem?: (index: number) => void;
|
|
43
|
+
onApply?: () => void;
|
|
44
|
+
onDiscard?: () => void;
|
|
45
|
+
applyLabel?: string;
|
|
46
|
+
/** 1-by-1 per-item action labels. Default "Keep" / "Drop". */
|
|
47
|
+
acceptLabel?: string;
|
|
48
|
+
rejectLabel?: string;
|
|
49
|
+
acceptAllLabel?: string;
|
|
50
|
+
/** Once resolved the card settles to a quiet outcome line (no actions). */
|
|
51
|
+
status?: "open" | "applied" | "discarded";
|
|
38
52
|
}
|
|
39
53
|
|
|
40
|
-
/** A git-style
|
|
41
|
-
* value on a green `+` line. An addition (no `before`) is just the `+` line.
|
|
42
|
-
|
|
54
|
+
/** A git-style diff body: the old value on a red `−` line (struck), the new
|
|
55
|
+
* value on a green `+` line. An addition (no `before`) is just the `+` line.
|
|
56
|
+
* The canonical `renderItem` for a single-field change — compose it under your
|
|
57
|
+
* own field label. */
|
|
58
|
+
export function ChangeDiff({ before, after }: { before?: string; after: string }) {
|
|
43
59
|
return (
|
|
44
60
|
<View style={styles.diff}>
|
|
45
61
|
{before != null ? (
|
|
@@ -65,23 +81,26 @@ function DiffLines({ before, after }: { before?: string; after: string }) {
|
|
|
65
81
|
}
|
|
66
82
|
|
|
67
83
|
/**
|
|
68
|
-
* An agent-proposed
|
|
69
|
-
* auto-applied.
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
84
|
+
* An agent-proposed set of changes, reviewed before it lands — never
|
|
85
|
+
* auto-applied. The component owns the review MECHANICS; `renderItem` owns how
|
|
86
|
+
* each proposal looks, so it serves a field diff, a record, or an editable form
|
|
87
|
+
* equally. Two modes. Whole-set: the list + apply / discard the lot. 1-BY-1
|
|
88
|
+
* (when the host passes `onAcceptItem`/`onRejectItem`): each proposal is its own
|
|
89
|
+
* card with Keep / Drop; deciding COLLAPSES the card to a preview with Undo, and
|
|
90
|
+
* a separate footer carries Accept-all + the single Apply that commits the kept
|
|
91
|
+
* set. Where a `ReviewCard` reviews a SINGLE proposal, this is the BATCH engine.
|
|
75
92
|
*/
|
|
76
|
-
export function ChangeReview(props: ChangeReviewProps) {
|
|
93
|
+
export function ChangeReview<T>(props: ChangeReviewProps<T>) {
|
|
77
94
|
const status = props.status ?? "open";
|
|
78
95
|
const perItem = props.onAcceptItem != null || props.onRejectItem != null;
|
|
79
|
-
const total = props.
|
|
80
|
-
const
|
|
96
|
+
const total = props.items.length;
|
|
97
|
+
const statusFor = (item: T, i: number) => props.statusOf?.(item, i) ?? "pending";
|
|
98
|
+
const keptCount = props.items.filter((it, i) => statusFor(it, i) === "accepted").length;
|
|
99
|
+
const title = props.title ?? "Suggested edits";
|
|
81
100
|
|
|
82
101
|
const acceptAll = () =>
|
|
83
|
-
props.
|
|
84
|
-
if ((
|
|
102
|
+
props.items.forEach((it, i) => {
|
|
103
|
+
if (statusFor(it, i) === "pending") props.onAcceptItem?.(i);
|
|
85
104
|
});
|
|
86
105
|
|
|
87
106
|
const summaryNote = props.summary ? (
|
|
@@ -92,13 +111,14 @@ export function ChangeReview(props: ChangeReviewProps) {
|
|
|
92
111
|
</View>
|
|
93
112
|
) : null;
|
|
94
113
|
|
|
95
|
-
// ── 1-BY-1: each
|
|
114
|
+
// ── 1-BY-1: each proposal a bordered card; deciding collapses it to a quiet,
|
|
115
|
+
// lighter row — visual weight tracks whether it still needs you ──────────
|
|
96
116
|
if (perItem) {
|
|
97
117
|
return (
|
|
98
|
-
<View style={
|
|
118
|
+
<View style={status !== "open" ? styles.resolvedCard : styles.stack}>
|
|
99
119
|
<View style={styles.head}>
|
|
100
120
|
<Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>
|
|
101
|
-
|
|
121
|
+
{title}
|
|
102
122
|
</Text>
|
|
103
123
|
{status === "open" ? (
|
|
104
124
|
<Text size="xs" color="muted" tabular>
|
|
@@ -115,41 +135,31 @@ export function ChangeReview(props: ChangeReviewProps) {
|
|
|
115
135
|
) : (
|
|
116
136
|
<>
|
|
117
137
|
<View style={styles.list}>
|
|
118
|
-
{props.
|
|
119
|
-
const st =
|
|
138
|
+
{props.items.map((item, i) => {
|
|
139
|
+
const st = statusFor(item, i);
|
|
120
140
|
if (st === "pending") {
|
|
121
141
|
return (
|
|
122
|
-
<View key={
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
<View style={styles.cardActions}>
|
|
128
|
-
<Button title="Drop" color="muted" shape="rounded" onPress={() => props.onRejectItem?.(i)} />
|
|
129
|
-
<Button title="Keep" color="secondary" shape="rounded" onPress={() => props.onAcceptItem?.(i)} />
|
|
142
|
+
<View key={props.getKey(item, i)} style={styles.itemCard}>
|
|
143
|
+
{props.renderItem(item, i)}
|
|
144
|
+
<View style={styles.itemActions}>
|
|
145
|
+
<Button title={props.rejectLabel ?? "Drop"} color="muted" shape="rounded" onPress={() => props.onRejectItem?.(i)} />
|
|
146
|
+
<Button title={props.acceptLabel ?? "Keep"} color="secondary" shape="rounded" onPress={() => props.onAcceptItem?.(i)} />
|
|
130
147
|
</View>
|
|
131
148
|
</View>
|
|
132
149
|
);
|
|
133
150
|
}
|
|
134
151
|
const kept = st === "accepted";
|
|
135
152
|
return (
|
|
136
|
-
<View key={
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
{kept ? c.after : c.before ?? c.after}
|
|
147
|
-
</Text>
|
|
148
|
-
<View style={styles.statusTag}>
|
|
149
|
-
<Icon name={kept ? "check" : "x"} size={14} color={kept ? colors.emerald[600] : colors.zinc[400]} />
|
|
150
|
-
<Text size="sm" weight="medium" color="muted">
|
|
151
|
-
{kept ? "Kept" : "Dropped"}
|
|
152
|
-
</Text>
|
|
153
|
+
<View key={props.getKey(item, i)} style={[styles.decidedRow, kept ? null : styles.decidedDrop]}>
|
|
154
|
+
<Icon name={kept ? "check" : "x"} size={15} color={kept ? colors.emerald[600] : colors.zinc[400]} />
|
|
155
|
+
<View style={{ flex: 1 }}>
|
|
156
|
+
{props.renderSummary ? (
|
|
157
|
+
props.renderSummary(item, kept, i)
|
|
158
|
+
) : (
|
|
159
|
+
<Text size="sm" color={kept ? "default" : "muted"} numberOfLines={1} style={kept ? null : styles.strike}>
|
|
160
|
+
{props.getTitle?.(item, i) ?? ""}
|
|
161
|
+
</Text>
|
|
162
|
+
)}
|
|
153
163
|
</View>
|
|
154
164
|
{props.onUndoItem ? (
|
|
155
165
|
<Button title="Undo" color="muted" shape="rounded" onPress={() => props.onUndoItem?.(i)} />
|
|
@@ -160,7 +170,7 @@ export function ChangeReview(props: ChangeReviewProps) {
|
|
|
160
170
|
</View>
|
|
161
171
|
|
|
162
172
|
<View style={styles.footer}>
|
|
163
|
-
<Button title="Accept all" color="muted" shape="rounded" onPress={acceptAll} />
|
|
173
|
+
<Button title={props.acceptAllLabel ?? "Accept all"} color="muted" shape="rounded" onPress={acceptAll} />
|
|
164
174
|
<View style={{ flex: 1 }} />
|
|
165
175
|
{props.onDiscard ? <Button title="Discard" color="muted" shape="rounded" onPress={props.onDiscard} /> : null}
|
|
166
176
|
{props.onApply ? (
|
|
@@ -173,20 +183,17 @@ export function ChangeReview(props: ChangeReviewProps) {
|
|
|
173
183
|
);
|
|
174
184
|
}
|
|
175
185
|
|
|
176
|
-
// ── WHOLE-SET: review the lot, apply / discard together
|
|
186
|
+
// ── WHOLE-SET: one bordered card, review the lot, apply / discard together ─
|
|
177
187
|
return (
|
|
178
|
-
<View style={[
|
|
188
|
+
<View style={[reviewCardStyle, status !== "open" ? reviewResolvedStyle : null]}>
|
|
179
189
|
<Text size="xs" color="muted" weight="medium">
|
|
180
|
-
|
|
190
|
+
{title}
|
|
181
191
|
</Text>
|
|
182
192
|
{summaryNote}
|
|
183
|
-
<View
|
|
184
|
-
{props.
|
|
185
|
-
<View key={
|
|
186
|
-
|
|
187
|
-
{c.label}
|
|
188
|
-
</Text>
|
|
189
|
-
<DiffLines before={c.before} after={c.after} />
|
|
193
|
+
<View>
|
|
194
|
+
{props.items.map((item, i) => (
|
|
195
|
+
<View key={props.getKey(item, i)} style={i > 0 ? styles.changeSpacer : undefined}>
|
|
196
|
+
{props.renderItem(item, i)}
|
|
190
197
|
</View>
|
|
191
198
|
))}
|
|
192
199
|
</View>
|
|
@@ -209,37 +216,37 @@ export function ChangeReview(props: ChangeReviewProps) {
|
|
|
209
216
|
|
|
210
217
|
const styles = {
|
|
211
218
|
...StyleSheet.create({
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
padding: 16,
|
|
218
|
-
gap: 14,
|
|
219
|
-
},
|
|
220
|
-
resolved: { backgroundColor: colors.zinc[50] },
|
|
219
|
+
// 1-by-1 outer: no chrome — the parent (dialog / message column) is the
|
|
220
|
+
// boundary. A nested card-in-card with two fills reads muddy. (Whole-set
|
|
221
|
+
// wears the shared `reviewCardStyle`; the resolved summary just stacks.)
|
|
222
|
+
stack: { gap: 14 },
|
|
223
|
+
resolvedCard: { gap: 14 },
|
|
221
224
|
head: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
222
|
-
note: { borderLeftWidth: 2, borderLeftColor: colors.zinc[
|
|
225
|
+
note: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 12 },
|
|
223
226
|
list: { gap: 8 },
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
+
// A PENDING proposal: a hairline-bordered card, no fill — it needs a decision.
|
|
228
|
+
itemCard: { borderWidth: 1, borderColor: colors.border, borderRadius: 12, padding: 16, gap: 14, backgroundColor: colors.white },
|
|
229
|
+
// Actions sit in a divided footer so they read intentional, not floating.
|
|
230
|
+
itemActions: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8, borderTopWidth: 1, borderTopColor: colors.zinc[100], paddingTop: 12 },
|
|
231
|
+
// A DECIDED proposal: a lighter, quieter row — resolved, out of the way.
|
|
232
|
+
decidedRow: {
|
|
227
233
|
flexDirection: "row",
|
|
228
234
|
alignItems: "center",
|
|
229
235
|
gap: 10,
|
|
230
|
-
|
|
236
|
+
borderWidth: 1,
|
|
237
|
+
borderColor: colors.zinc[100],
|
|
231
238
|
borderRadius: 10,
|
|
232
|
-
paddingLeft:
|
|
239
|
+
paddingLeft: 12,
|
|
233
240
|
paddingRight: 8,
|
|
234
241
|
paddingVertical: 6,
|
|
235
|
-
minHeight:
|
|
242
|
+
minHeight: 44,
|
|
236
243
|
},
|
|
237
|
-
|
|
238
|
-
changeSpacer: { marginTop:
|
|
244
|
+
decidedDrop: { backgroundColor: colors.zinc[50] },
|
|
245
|
+
changeSpacer: { marginTop: 12, borderTopWidth: 1, borderTopColor: colors.zinc[100], paddingTop: 12 },
|
|
239
246
|
diff: { gap: 3 },
|
|
240
247
|
diffLine: { flexDirection: "row", alignItems: "baseline", gap: 8 },
|
|
241
248
|
strike: { textDecorationLine: "line-through" },
|
|
242
|
-
footer: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
249
|
+
footer: { flexDirection: "row", alignItems: "center", gap: 8, borderTopWidth: 1, borderTopColor: colors.zinc[200], paddingTop: 14 },
|
|
243
250
|
}),
|
|
244
251
|
marker: (color: string) => ({ color, width: 12 }),
|
|
245
252
|
value: (color: string) => ({ color }),
|
package/src/combobox.tsx
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "react-native";
|
|
10
10
|
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
|
11
11
|
import { colors } from "./colors";
|
|
12
|
-
import { ACTIVE_RING } from "./control_surface";
|
|
12
|
+
import { ACTIVE_RING, inputPillStyle } from "./control_surface";
|
|
13
13
|
import { Text } from "./text";
|
|
14
14
|
import { Icon, type IconName } from "./icon";
|
|
15
15
|
import { TextInputField } from "./text_input_field";
|
|
@@ -70,6 +70,11 @@ export interface ComboboxProps<T extends string = string, D = unknown> {
|
|
|
70
70
|
* picker, not a free-text search box. Opt IN to the search-box look explicitly
|
|
71
71
|
* with `icon="search"` (leading glyph, no chevron). Multi-select shows neither. */
|
|
72
72
|
icon?: IconName;
|
|
73
|
+
/** Render the input on the white toolbar-pill surface (rounded-full, white
|
|
74
|
+
* fill, zinc-300 border) — the same surface SearchInput/ChipGroup/FilterPill
|
|
75
|
+
* wear — so a combobox used as a toolbar filter reads as a view-control on the
|
|
76
|
+
* zinc-50 canvas, not a form field. Single-select only. */
|
|
77
|
+
pill?: boolean;
|
|
73
78
|
placeholder?: string;
|
|
74
79
|
recentsLabel?: string;
|
|
75
80
|
emptyText?: string;
|
|
@@ -123,6 +128,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
|
|
|
123
128
|
loading = false,
|
|
124
129
|
searchDebounceMs = 200,
|
|
125
130
|
icon,
|
|
131
|
+
pill = false,
|
|
126
132
|
placeholder,
|
|
127
133
|
recentsLabel = "Recent",
|
|
128
134
|
emptyText = "No results",
|
|
@@ -340,6 +346,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
|
|
|
340
346
|
autoCorrect={false}
|
|
341
347
|
style={[
|
|
342
348
|
multi ? styles.multiInput : showChevron ? styles.selectInput : undefined,
|
|
349
|
+
!multi && pill ? inputPillStyle : undefined,
|
|
343
350
|
// Single-select wears the open ring on the input (which holds the
|
|
344
351
|
// border); multi wears it on the wrapper above (the input is borderless).
|
|
345
352
|
// Gate on `showList`, not `open`: an empty query with no options has
|
package/src/confidence.tsx
CHANGED
|
@@ -26,7 +26,7 @@ export function levelFromScore(score: number): ConfidenceLevel {
|
|
|
26
26
|
* How sure the AI is — a calibrated high / medium / low read as a 3-tick meter
|
|
27
27
|
* (emerald / amber / zinc) beside the level word. The fill count and the colour
|
|
28
28
|
* both carry the level. The human weights an AI proposal or estimate by it. Pair
|
|
29
|
-
* with `
|
|
29
|
+
* with `ReviewCard` or `ChangeReview`.
|
|
30
30
|
*/
|
|
31
31
|
export function Confidence(props: ConfidenceProps) {
|
|
32
32
|
const level = props.level ?? (props.score != null ? levelFromScore(props.score) : "medium");
|
package/src/control_surface.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ViewStyle } from "react-native";
|
|
1
|
+
import { type TextStyle, type ViewStyle } from "react-native";
|
|
2
2
|
import { colors } from "./colors";
|
|
3
3
|
|
|
4
4
|
/** The system control height — every band control (chips, pills, search,
|
|
@@ -13,6 +13,32 @@ export const CONTROL_HEIGHT = 40;
|
|
|
13
13
|
* beside the kit's focus outline and selected-pill ring. */
|
|
14
14
|
export const ACTIVE_RING = `0 0 0 2px ${colors.zinc[900]}`;
|
|
15
15
|
|
|
16
|
+
/** THE white toolbar-input surface — a rounded-full white pill with a one-step-up
|
|
17
|
+
* zinc-300 border. A search/select control (SearchInput, and Combobox via `pill`)
|
|
18
|
+
* wears it so it reads as a view-control beside ChipGroup/FilterPill on the
|
|
19
|
+
* zinc-50 canvas (where the fill pops), not as a form field that vanishes into a
|
|
20
|
+
* white card. One definition so the two controls never drift. */
|
|
21
|
+
export const inputPillStyle: TextStyle = {
|
|
22
|
+
borderRadius: 999,
|
|
23
|
+
backgroundColor: colors.white,
|
|
24
|
+
borderColor: colors.zinc[300],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** THE review-card surface — the bordered white card every "AI proposes, you
|
|
28
|
+
* decide" surface wears (`ReviewCard`, and the whole-set `ChangeReview`), so the
|
|
29
|
+
* review family reads as ONE contract instead of look-alikes drifting apart. One
|
|
30
|
+
* definition; each adds its own header / body / footer on top.
|
|
31
|
+
* `reviewResolvedStyle` is the settled wash once decided. */
|
|
32
|
+
export const reviewCardStyle: ViewStyle = {
|
|
33
|
+
borderWidth: 1,
|
|
34
|
+
borderColor: colors.border,
|
|
35
|
+
backgroundColor: colors.white,
|
|
36
|
+
borderRadius: 12,
|
|
37
|
+
padding: 16,
|
|
38
|
+
gap: 12,
|
|
39
|
+
};
|
|
40
|
+
export const reviewResolvedStyle: ViewStyle = { backgroundColor: colors.zinc[50] };
|
|
41
|
+
|
|
16
42
|
/**
|
|
17
43
|
* THE pill-surface contract — the single definition of the bordered, white,
|
|
18
44
|
* rounded interactive surface shared by `ChipGroup` chips and `PillButton` (and
|
package/src/file_thumbnail.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { fontFamilySemiBold } from "./text_utils";
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { Icon, type IconName } from "./icon";
|
|
5
5
|
import { Checkbox } from "./checkbox";
|
|
6
|
+
import { ActivityIndicator } from "./activity_indicator";
|
|
6
7
|
import { colors } from "./colors";
|
|
7
8
|
import { useCallback, useState } from "react";
|
|
8
9
|
import {
|
|
@@ -66,6 +67,9 @@ interface FileThumbnailProps {
|
|
|
66
67
|
onRemove?: () => void;
|
|
67
68
|
/** When defined, renders a selection overlay with a checkbox. */
|
|
68
69
|
selected?: boolean;
|
|
70
|
+
/** While true, dims the thumbnail with a spinner — the file is still uploading
|
|
71
|
+
* (the local preview shows immediately; this marks it as in-flight). */
|
|
72
|
+
uploading?: boolean;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
/**
|
|
@@ -73,7 +77,7 @@ interface FileThumbnailProps {
|
|
|
73
77
|
* Works with any file source via DisplayFile interface.
|
|
74
78
|
*/
|
|
75
79
|
export function FileThumbnail(props: FileThumbnailProps) {
|
|
76
|
-
const { file, size, onPress, onLongPress, onRemove, selected } = props;
|
|
80
|
+
const { file, size, onPress, onLongPress, onRemove, selected, uploading } = props;
|
|
77
81
|
|
|
78
82
|
const rootStyle =
|
|
79
83
|
size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
|
|
@@ -82,6 +86,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
|
|
|
82
86
|
return (
|
|
83
87
|
<View style={rootStyle}>
|
|
84
88
|
<ImageThumbnail file={file} size={size} onPress={onPress} onLongPress={onLongPress} />
|
|
89
|
+
{uploading && <UploadingOverlay size={size} />}
|
|
85
90
|
{onRemove && <RemoveButton onPress={onRemove} />}
|
|
86
91
|
{selected !== undefined && <SelectionOverlay selected={selected} />}
|
|
87
92
|
</View>
|
|
@@ -99,6 +104,7 @@ export function FileThumbnail(props: FileThumbnailProps) {
|
|
|
99
104
|
onPress={onPress ?? (() => Linking.openURL(file.url))}
|
|
100
105
|
onLongPress={onLongPress}
|
|
101
106
|
/>
|
|
107
|
+
{uploading && <UploadingOverlay size={size} />}
|
|
102
108
|
{onRemove && <RemoveButton onPress={onRemove} />}
|
|
103
109
|
</View>
|
|
104
110
|
);
|
|
@@ -124,12 +130,37 @@ export function FileThumbnail(props: FileThumbnailProps) {
|
|
|
124
130
|
onLongPress={onLongPress}
|
|
125
131
|
/>
|
|
126
132
|
)}
|
|
133
|
+
{uploading && <UploadingOverlay size={size} />}
|
|
127
134
|
{onRemove && <RemoveButton onPress={onRemove} />}
|
|
128
135
|
{selected !== undefined && <SelectionOverlay selected={selected} />}
|
|
129
136
|
</View>
|
|
130
137
|
);
|
|
131
138
|
}
|
|
132
139
|
|
|
140
|
+
/** A dim + spinner overlay shown while a file is still uploading. `pointerEvents`
|
|
141
|
+
* none so the RemoveButton beneath it stays clickable (cancel an in-flight upload). */
|
|
142
|
+
function UploadingOverlay({ size }: { size?: number }) {
|
|
143
|
+
return (
|
|
144
|
+
<View style={uploadingOverlayStyles.overlay} pointerEvents="none">
|
|
145
|
+
<ActivityIndicator size={size !== undefined && size <= COMPACT_THUMBNAIL_SIZE ? 12 : 18} color={colors.white} />
|
|
146
|
+
</View>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const uploadingOverlayStyles = StyleSheet.create({
|
|
151
|
+
overlay: {
|
|
152
|
+
position: "absolute",
|
|
153
|
+
top: 0,
|
|
154
|
+
left: 0,
|
|
155
|
+
right: 0,
|
|
156
|
+
bottom: 0,
|
|
157
|
+
alignItems: "center",
|
|
158
|
+
justifyContent: "center",
|
|
159
|
+
backgroundColor: "rgba(24,24,27,0.42)",
|
|
160
|
+
borderRadius: 8,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
133
164
|
// =============================================================================
|
|
134
165
|
// Base Components (exported for direct use when needed)
|
|
135
166
|
// =============================================================================
|
package/src/icon.tsx
CHANGED
|
@@ -116,6 +116,7 @@ import LockOpen from "lucide-react-native/dist/esm/icons/lock-open";
|
|
|
116
116
|
import LogIn from "lucide-react-native/dist/esm/icons/log-in";
|
|
117
117
|
import LogOut from "lucide-react-native/dist/esm/icons/log-out";
|
|
118
118
|
import Mail from "lucide-react-native/dist/esm/icons/mail";
|
|
119
|
+
import MapPin from "lucide-react-native/dist/esm/icons/map-pin";
|
|
119
120
|
import Maximize2 from "lucide-react-native/dist/esm/icons/maximize-2";
|
|
120
121
|
import Megaphone from "lucide-react-native/dist/esm/icons/megaphone";
|
|
121
122
|
import Mic from "lucide-react-native/dist/esm/icons/mic";
|
|
@@ -310,6 +311,7 @@ const iconComponents = {
|
|
|
310
311
|
"log-in": LogIn,
|
|
311
312
|
"log-out": LogOut,
|
|
312
313
|
mail: Mail,
|
|
314
|
+
"map-pin": MapPin,
|
|
313
315
|
"maximize-2": Maximize2,
|
|
314
316
|
megaphone: Megaphone,
|
|
315
317
|
menu: Menu,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { Icon } from "./icon";
|
|
6
|
+
|
|
7
|
+
export interface MatchSideTextProps {
|
|
8
|
+
/** Primary line — the record name / id. */
|
|
9
|
+
title: string;
|
|
10
|
+
/** Secondary line — amount, date, customer. */
|
|
11
|
+
detail?: string;
|
|
12
|
+
/** Right-align for the proposed (right) side of a pairing. */
|
|
13
|
+
align?: "left" | "right";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The DEFAULT side content — a title + a muted detail line. Pass it into a
|
|
18
|
+
* `MatchSides` slot for the common case; drop a different node in when a side
|
|
19
|
+
* needs more (a `Peek` to preview the record, an interactive card, a thumbnail).
|
|
20
|
+
*/
|
|
21
|
+
export function MatchSideText({ title, detail, align }: MatchSideTextProps) {
|
|
22
|
+
const right = align === "right";
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<Text size="sm" weight="semibold" numberOfLines={1} align={right ? "right" : undefined}>
|
|
26
|
+
{title}
|
|
27
|
+
</Text>
|
|
28
|
+
{detail ? (
|
|
29
|
+
<Text size="xs" color="muted" numberOfLines={1} align={right ? "right" : undefined}>
|
|
30
|
+
{detail}
|
|
31
|
+
</Text>
|
|
32
|
+
) : null}
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MatchSidesProps {
|
|
38
|
+
/** Left content — ANY node. `MatchSideText` for the default, or a `Peek` /
|
|
39
|
+
* interactive card / thumbnail when a side needs to do more. */
|
|
40
|
+
source: ReactNode;
|
|
41
|
+
/** Right content. Omit → the "no confident match" placeholder. */
|
|
42
|
+
match?: ReactNode;
|
|
43
|
+
/** The connector between the sides. Default: a right arrow. Override to carry
|
|
44
|
+
* a swap control, a label, or a different glyph. */
|
|
45
|
+
connector?: ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The two-sided PAIRING — a left slot, a connector, a right slot. A pure LAYOUT
|
|
50
|
+
* primitive: it owns the two-column arrangement and the connector, the CONTENT
|
|
51
|
+
* of each side is yours (`MatchSideText`, a `Peek`, anything). The hero of a
|
|
52
|
+
* reconciliation; the confidence, reason, and decision come from the `ReviewCard`.
|
|
53
|
+
*/
|
|
54
|
+
export function MatchSides(props: MatchSidesProps) {
|
|
55
|
+
return (
|
|
56
|
+
<View style={styles.pair}>
|
|
57
|
+
<View style={styles.side}>{props.source}</View>
|
|
58
|
+
{props.connector ?? <Icon name="arrow-right" size={16} color={colors.zinc[400]} />}
|
|
59
|
+
<View style={[styles.side, styles.sideRight]}>
|
|
60
|
+
{props.match ?? (
|
|
61
|
+
<>
|
|
62
|
+
<Text size="sm" weight="medium" color="muted" align="right" numberOfLines={1}>
|
|
63
|
+
No confident match
|
|
64
|
+
</Text>
|
|
65
|
+
<Text size="xs" color="muted" align="right" numberOfLines={1}>
|
|
66
|
+
Pick a counterpart
|
|
67
|
+
</Text>
|
|
68
|
+
</>
|
|
69
|
+
)}
|
|
70
|
+
</View>
|
|
71
|
+
</View>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const styles = StyleSheet.create({
|
|
76
|
+
pair: { flexDirection: "row", alignItems: "center", gap: 14 },
|
|
77
|
+
side: { flex: 1, gap: 3, minWidth: 0 },
|
|
78
|
+
sideRight: { alignItems: "flex-end" },
|
|
79
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { solid } from "./colors";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { InlineTextInput } from "./inline_text_input";
|
|
5
|
+
|
|
6
|
+
export interface RecordField {
|
|
7
|
+
label: string;
|
|
8
|
+
value: string;
|
|
9
|
+
/** Unit suffix shown after the value (₫, kg, pcs). */
|
|
10
|
+
unit?: string;
|
|
11
|
+
/** Mark a low-confidence field worth double-checking — a subtle amber dot. */
|
|
12
|
+
uncertain?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RecordFieldsProps {
|
|
16
|
+
fields: RecordField[];
|
|
17
|
+
/** Edit a field — fired on save of the inline editor (blur / Enter). When set,
|
|
18
|
+
* each value is click-to-edit; omit for a read-only record. */
|
|
19
|
+
onEditField?: (index: number, value: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The BODY of an extracted record — a label/value list, each value click-to-edit
|
|
24
|
+
* (`InlineTextInput`) or read-only. A pure body atom: drop it into a `ReviewCard`
|
|
25
|
+
* to review-before-save (a confidence + Confirm / Remove, stacked under a Save-all
|
|
26
|
+
* → `tpl_extract`), or use it standalone as a live-edit field card (a params
|
|
27
|
+
* panel). The review MECHANICS belong to the host / `ReviewCard`, not here.
|
|
28
|
+
*/
|
|
29
|
+
export function RecordFields(props: RecordFieldsProps) {
|
|
30
|
+
return (
|
|
31
|
+
<View style={styles.fields}>
|
|
32
|
+
{props.fields.map((f, i) => (
|
|
33
|
+
<View key={`${f.label}-${i}`} style={styles.fieldRow}>
|
|
34
|
+
<View style={styles.labelCol}>
|
|
35
|
+
{f.uncertain ? <View style={styles.uncertainDot} /> : null}
|
|
36
|
+
<Text size="sm" color="muted" numberOfLines={1}>
|
|
37
|
+
{f.label}
|
|
38
|
+
</Text>
|
|
39
|
+
</View>
|
|
40
|
+
<View style={styles.valueCol}>
|
|
41
|
+
<View style={{ flex: 1 }}>
|
|
42
|
+
{props.onEditField ? (
|
|
43
|
+
<InlineTextInput value={f.value} accessibilityLabel={`Edit ${f.label}`} onSave={(next) => props.onEditField?.(i, next)} />
|
|
44
|
+
) : (
|
|
45
|
+
<Text size="sm" weight="medium" tabular>
|
|
46
|
+
{f.value}
|
|
47
|
+
</Text>
|
|
48
|
+
)}
|
|
49
|
+
</View>
|
|
50
|
+
{f.unit ? (
|
|
51
|
+
<Text size="sm" color="muted">
|
|
52
|
+
{f.unit}
|
|
53
|
+
</Text>
|
|
54
|
+
) : null}
|
|
55
|
+
</View>
|
|
56
|
+
</View>
|
|
57
|
+
))}
|
|
58
|
+
</View>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const styles = StyleSheet.create({
|
|
63
|
+
fields: { gap: 2 },
|
|
64
|
+
fieldRow: { flexDirection: "row", alignItems: "center", gap: 16, minHeight: 34 },
|
|
65
|
+
labelCol: { flexDirection: "row", alignItems: "center", gap: 6, width: 132 },
|
|
66
|
+
uncertainDot: { width: 6, height: 6, borderRadius: 999, backgroundColor: solid("amber") },
|
|
67
|
+
valueCol: { flex: 1, flexDirection: "row", alignItems: "center", gap: 6 },
|
|
68
|
+
});
|