@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.
@@ -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 ChangeReviewItem {
10
- /** What changed a field/parameter name. */
11
- label: string;
12
- /** Prior value. Omit for a newly-added value (renders as just the `+` line). */
13
- before?: string;
14
- /** Proposed value. */
15
- after: string;
16
- /** Per-item decisiononly meaningful in the 1-by-1 mode (when the host
17
- * passes `onAcceptItem`/`onRejectItem`). */
18
- status?: ChangeReviewItemStatus;
19
- }
20
-
21
- export interface ChangeReviewProps {
22
- changes: ChangeReviewItem[];
23
- /** The instruction that produced the edit ("make it 5 mm taller"). */
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
- onApply?: () => void;
26
- onDiscard?: () => void;
27
- applyLabel?: string;
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 stacked diff: the old value on a red `−` line (struck), the new
41
- * value on a green `+` line. An addition (no `before`) is just the `+` line. */
42
- function DiffLines({ before, after }: { before?: string; after: string }) {
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 edit to existing state, reviewed before it lands — never
69
- * auto-applied. Two modes. Whole-set: a list of before→after diffs + apply /
70
- * discard the lot. 1-BY-1 (when the host passes `onAcceptItem`/`onRejectItem`):
71
- * each edit is its own card with Keep / Drop; deciding COLLAPSES the card to a
72
- * preview with Undo, and a separate footer carries Accept-all + the single Apply
73
- * that commits the kept set. Where `Suggestion` proposes a NEW value, this
74
- * reviews CHANGES to existing ones.
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.changes.length;
80
- const keptCount = props.changes.filter((c) => c.status === "accepted").length;
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.changes.forEach((c, i) => {
84
- if ((c.status ?? "pending") === "pending") props.onAcceptItem?.(i);
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 edit a card; deciding collapses it to a preview ──────────
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={[styles.card, status !== "open" ? styles.resolved : null]}>
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
- Suggested edits
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.changes.map((c, i) => {
119
- const st = c.status ?? "pending";
138
+ {props.items.map((item, i) => {
139
+ const st = statusFor(item, i);
120
140
  if (st === "pending") {
121
141
  return (
122
- <View key={`${c.label}-${i}`} style={styles.block}>
123
- <Text size="xs" color="muted" weight="medium">
124
- {c.label}
125
- </Text>
126
- <DiffLines before={c.before} after={c.after} />
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={`${c.label}-${i}`} style={styles.collapsed}>
137
- <Text size="sm" weight="medium" numberOfLines={1}>
138
- {c.label}
139
- </Text>
140
- <Text
141
- size="sm"
142
- color={kept ? "default" : "muted"}
143
- numberOfLines={1}
144
- style={[{ flex: 1 }, kept ? null : styles.strike]}
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={[styles.card, status !== "open" ? styles.resolved : null]}>
188
+ <View style={[reviewCardStyle, status !== "open" ? reviewResolvedStyle : null]}>
179
189
  <Text size="xs" color="muted" weight="medium">
180
- Suggested edit
190
+ {title}
181
191
  </Text>
182
192
  {summaryNote}
183
- <View style={styles.block}>
184
- {props.changes.map((c, i) => (
185
- <View key={`${c.label}-${i}`} style={i > 0 ? styles.changeSpacer : undefined}>
186
- <Text size="xs" color="muted" weight="medium">
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
- card: {
213
- borderWidth: 1,
214
- borderColor: colors.border,
215
- backgroundColor: colors.white,
216
- borderRadius: 12,
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[300], paddingLeft: 12 },
225
+ note: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 12 },
223
226
  list: { gap: 8 },
224
- block: { backgroundColor: colors.zinc[50], borderRadius: 10, padding: 14, gap: 10 },
225
- cardActions: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 },
226
- collapsed: {
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
- backgroundColor: colors.zinc[50],
236
+ borderWidth: 1,
237
+ borderColor: colors.zinc[100],
231
238
  borderRadius: 10,
232
- paddingLeft: 14,
239
+ paddingLeft: 12,
233
240
  paddingRight: 8,
234
241
  paddingVertical: 6,
235
- minHeight: 48,
242
+ minHeight: 44,
236
243
  },
237
- statusTag: { flexDirection: "row", alignItems: "center", gap: 4 },
238
- changeSpacer: { marginTop: 10, borderTopWidth: 1, borderTopColor: colors.zinc[200], paddingTop: 10 },
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
@@ -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 `Suggestion` and `RecordReview`.
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");
@@ -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
@@ -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
+ });