@lotics/ui 7.19.3 → 8.0.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 +128 -92
- package/examples/tpl_dieline.tsx +3 -3
- package/examples/tpl_documents.tsx +790 -0
- package/examples/tpl_lookup.tsx +37 -10
- package/examples/tpl_task_board.tsx +32 -19
- package/examples/tpl_tasks.tsx +32 -19
- package/package.json +2 -9
- package/src/agent_run.tsx +1 -1
- package/src/change_review.tsx +732 -236
- package/src/composer.tsx +22 -1
- package/src/confidence.tsx +1 -1
- package/src/control_surface.ts +0 -14
- package/src/finding.tsx +112 -80
- package/src/floating_action_bar.tsx +1 -1
- package/src/locale.tsx +22 -16
- package/src/sources.tsx +8 -5
- package/examples/tpl_assistant.tsx +0 -174
- package/examples/tpl_briefing.tsx +0 -121
- package/examples/tpl_compare.tsx +0 -133
- package/examples/tpl_crosscheck.tsx +0 -120
- package/examples/tpl_draft.tsx +0 -163
- package/examples/tpl_extract.tsx +0 -193
- package/examples/tpl_match.tsx +0 -134
- package/examples/tpl_ratedesk.tsx +0 -386
- package/examples/tpl_triage.tsx +0 -112
- package/src/discrepancy.tsx +0 -114
- package/src/match_sides.tsx +0 -79
- package/src/record_fields.tsx +0 -68
- package/src/review_card.tsx +0 -172
- package/src/scored_option.tsx +0 -138
- package/src/spec_list.tsx +0 -81
- package/src/triage_row.tsx +0 -99
package/src/change_review.tsx
CHANGED
|
@@ -1,259 +1,732 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
createContext,
|
|
4
|
+
isValidElement,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from "react";
|
|
13
|
+
import { StyleSheet, TextInput as RNTextInput, View } from "react-native";
|
|
3
14
|
import { colors } from "./colors";
|
|
4
|
-
import { reviewCardStyle, reviewResolvedStyle } from "./control_surface";
|
|
5
15
|
import { Text } from "./text";
|
|
6
16
|
import { Icon } from "./icon";
|
|
7
17
|
import { Button } from "./button";
|
|
18
|
+
import { TextInputField } from "./text_input_field";
|
|
19
|
+
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
20
|
+
import { CardSelectItem } from "./card_select_item";
|
|
8
21
|
import { useLoticsLocale } from "./locale";
|
|
22
|
+
import { fontFamilyMedium } from "./text_utils";
|
|
9
23
|
|
|
10
|
-
export type
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
24
|
+
export type ChangeStatus = "pending" | "accepted" | "rejected";
|
|
25
|
+
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
// ChangeReview — THE review-before-apply surface, as a COMPOUND family. The
|
|
28
|
+
// host composes exactly what each change shows; the family owns the review
|
|
29
|
+
// mechanics (decide → collapse → undo, the kept counter, the commit bar).
|
|
30
|
+
//
|
|
31
|
+
// <ChangeReview>
|
|
32
|
+
// <ChangeReviewHeader /> ← title + auto "N/M kept"
|
|
33
|
+
// <Change id="vessel" status={…} onAccept={…} onReject={…} onUndo={…}>
|
|
34
|
+
// <ChangeLabel>Vessel</ChangeLabel>
|
|
35
|
+
// …any body — ChangeFields, a finding, prose…
|
|
36
|
+
// <ChangeSummary>Vessel → MAERSK SALINA</ChangeSummary> ← decided row
|
|
37
|
+
// </Change>
|
|
38
|
+
// …
|
|
39
|
+
// <ChangeReviewActions onApply={…} onDiscard={…} />
|
|
40
|
+
// </ChangeReview>
|
|
41
|
+
//
|
|
42
|
+
// State is the HOST's (controlled `status` per Change / ChangeRecord) — the
|
|
43
|
+
// context only mirrors it so the header counter, Keep-all, and the
|
|
44
|
+
// apply-disabled logic derive without the host re-wiring them. An add, an
|
|
45
|
+
// update, a removal and a source conflict are compositions of ONE grammar:
|
|
46
|
+
// `ChangeFields` + `ChangeField` (the − band · the + value · candidate rows)
|
|
47
|
+
// — not separate components; `ChangeRecord` wraps the same grammar in the
|
|
48
|
+
// per-item card for record SETS.
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
interface ChangeEntry {
|
|
52
|
+
status: ChangeStatus;
|
|
53
|
+
accept?: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ChangeReviewCtx {
|
|
57
|
+
register: (id: string, entry: ChangeEntry) => void;
|
|
58
|
+
unregister: (id: string) => void;
|
|
59
|
+
entries: ReadonlyMap<string, ChangeEntry>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const Ctx = createContext<ChangeReviewCtx | null>(null);
|
|
63
|
+
|
|
64
|
+
const useReviewCtx = (who: string): ChangeReviewCtx => {
|
|
65
|
+
const ctx = useContext(Ctx);
|
|
66
|
+
if (!ctx) throw new Error(`${who} must render inside <ChangeReview>`);
|
|
67
|
+
return ctx;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Mirrors a decidable entry's controlled state into the review context — the
|
|
71
|
+
* registration `Change` and `ChangeRecord` share (the header counter, Keep-all
|
|
72
|
+
* and the apply gate derive from the registry). */
|
|
73
|
+
function useRegisterEntry(who: string, id: string, status: ChangeStatus, onAccept?: () => void) {
|
|
74
|
+
const { register, unregister } = useReviewCtx(who);
|
|
75
|
+
const acceptRef = useRef(onAccept);
|
|
76
|
+
acceptRef.current = onAccept;
|
|
77
|
+
const stableAccept = useCallback(() => acceptRef.current?.(), []);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
register(id, { status, accept: onAccept ? stableAccept : undefined });
|
|
80
|
+
}, [register, id, status, onAccept, stableAccept]);
|
|
81
|
+
useEffect(() => () => unregister(id), [unregister, id]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function ChangeReview({ children }: { children: ReactNode }) {
|
|
85
|
+
const [entries, setEntries] = useState<Map<string, ChangeEntry>>(new Map());
|
|
86
|
+
const register = useCallback((id: string, entry: ChangeEntry) => {
|
|
87
|
+
setEntries((m) => {
|
|
88
|
+
const cur = m.get(id);
|
|
89
|
+
if (cur && cur.status === entry.status && cur.accept === entry.accept) return m;
|
|
90
|
+
const n = new Map(m);
|
|
91
|
+
n.set(id, entry);
|
|
92
|
+
return n;
|
|
93
|
+
});
|
|
94
|
+
}, []);
|
|
95
|
+
const unregister = useCallback((id: string) => {
|
|
96
|
+
setEntries((m) => {
|
|
97
|
+
if (!m.has(id)) return m;
|
|
98
|
+
const n = new Map(m);
|
|
99
|
+
n.delete(id);
|
|
100
|
+
return n;
|
|
101
|
+
});
|
|
102
|
+
}, []);
|
|
103
|
+
const value = useMemo(() => ({ register, unregister, entries }), [register, unregister, entries]);
|
|
104
|
+
// Containerless: changes are OPEN sections separated by hairline dividers —
|
|
105
|
+
// spacing and rules, never card boxes (controls keep their own affordances).
|
|
106
|
+
const kids = Children.toArray(children);
|
|
107
|
+
const withDividers: ReactNode[] = [];
|
|
108
|
+
kids.forEach((k, i) => {
|
|
109
|
+
const prev = kids[i - 1];
|
|
110
|
+
if (i > 0 && isValidElement(k) && k.type === Change && isValidElement(prev) && prev.type === Change) {
|
|
111
|
+
withDividers.push(<View key={`div-${i}`} style={styles.divider} />);
|
|
112
|
+
}
|
|
113
|
+
withDividers.push(k);
|
|
114
|
+
});
|
|
115
|
+
return (
|
|
116
|
+
<Ctx.Provider value={value}>
|
|
117
|
+
<View style={styles.stack}>{withDividers}</View>
|
|
118
|
+
</Ctx.Provider>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Title line + the automatic "N of M kept" counter (derived from the
|
|
123
|
+
* registered `Change`s / `ChangeRecord`s). `children` slots extra header
|
|
124
|
+
* content on the right. */
|
|
125
|
+
export function ChangeReviewHeader({ title, children }: { title?: string; children?: ReactNode }) {
|
|
126
|
+
const loc = useLoticsLocale().changeReview;
|
|
127
|
+
return (
|
|
128
|
+
<View style={styles.header}>
|
|
129
|
+
<Text size="md" weight="semibold" style={{ flex: 1 }}>
|
|
130
|
+
{title ?? loc.title}
|
|
131
|
+
</Text>
|
|
132
|
+
{children}
|
|
133
|
+
</View>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface ChangeProps {
|
|
138
|
+
/** Stable identity — the counter/commit derive from registered ids. */
|
|
139
|
+
id: string;
|
|
140
|
+
/** The HOST owns the decision. */
|
|
141
|
+
status?: ChangeStatus;
|
|
142
|
+
onAccept?: () => void;
|
|
143
|
+
onReject?: () => void;
|
|
144
|
+
/** Revert a decided change back to pending (the Undo on the collapsed row). */
|
|
145
|
+
onUndo?: () => void;
|
|
146
|
+
/** Labels for the per-change actions. Defaults localize ("Keep"/"Drop"). */
|
|
46
147
|
acceptLabel?: string;
|
|
47
148
|
rejectLabel?: string;
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
* the commit-bar labels live on `ChangeReviewActions`). */
|
|
52
|
-
labels?: {
|
|
53
|
-
undo?: string;
|
|
54
|
-
applied?: string;
|
|
55
|
-
discarded?: string;
|
|
56
|
-
/** The "N of M kept" counter — default `${kept} of ${total} kept`. */
|
|
57
|
-
keptCount?: (kept: number, total: number) => string;
|
|
58
|
-
};
|
|
149
|
+
/** Disable Accept until a precondition holds (an unresolved conflict). */
|
|
150
|
+
acceptDisabled?: boolean;
|
|
151
|
+
children: ReactNode;
|
|
59
152
|
}
|
|
60
153
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
154
|
+
/**
|
|
155
|
+
* ONE proposed change. Pending → renders the composed body + a labeled action
|
|
156
|
+
* row (only the callbacks you pass render buttons — omit both for a display-
|
|
157
|
+
* only entry). Decided → collapses to the `ChangeSummary` child (falls back to
|
|
158
|
+
* the body) behind a status mark + Undo. The body is ANY composition —
|
|
159
|
+
* `ChangeLabel` + `ChangeFields` for a record's fields, a finding, an
|
|
160
|
+
* editable form.
|
|
161
|
+
*/
|
|
162
|
+
export function Change(props: ChangeProps) {
|
|
163
|
+
const loc = useLoticsLocale().changeReview;
|
|
164
|
+
const status = props.status ?? "pending";
|
|
165
|
+
useRegisterEntry("Change", props.id, status, props.onAccept);
|
|
166
|
+
|
|
167
|
+
const kids = Children.toArray(props.children);
|
|
168
|
+
const summary = kids.filter((k) => isValidElement(k) && k.type === ChangeSummary);
|
|
169
|
+
const header = kids.filter((k) => isValidElement(k) && k.type === ChangeLabel);
|
|
170
|
+
const body = kids.filter((k) => !(isValidElement(k) && (k.type === ChangeSummary || k.type === ChangeLabel)));
|
|
171
|
+
|
|
172
|
+
if (status !== "pending") {
|
|
173
|
+
const kept = status === "accepted";
|
|
174
|
+
return (
|
|
175
|
+
<View style={styles.decidedRow}>
|
|
176
|
+
<Icon name={kept ? "check" : "x"} size={15} color={kept ? colors.emerald[600] : colors.zinc[400]} />
|
|
177
|
+
<View style={{ flex: 1, opacity: kept ? 1 : 0.6 }}>
|
|
178
|
+
{summary.length > 0 ? summary : <View style={styles.summaryFallback}>{body}</View>}
|
|
179
|
+
</View>
|
|
180
|
+
{props.onUndo ? <Button title={loc.undo} color="muted" onPress={props.onUndo} /> : null}
|
|
181
|
+
</View>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
77
184
|
|
|
78
|
-
|
|
79
|
-
* value on a green `+` line. An addition (no `before`) is just the `+` line.
|
|
80
|
-
* The canonical `renderItem` for a single-field change — compose it under your
|
|
81
|
-
* own field label. */
|
|
82
|
-
export function ChangeDiff({ before, after }: { before?: string; after: string }) {
|
|
185
|
+
const hasActions = props.onAccept != null || props.onReject != null;
|
|
83
186
|
return (
|
|
84
|
-
<View style={styles.
|
|
85
|
-
{
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
{
|
|
92
|
-
|
|
187
|
+
<View style={styles.changeSection}>
|
|
188
|
+
{header.length > 0 ? header : null}
|
|
189
|
+
<View style={styles.changeBody}>{body}</View>
|
|
190
|
+
{hasActions ? (
|
|
191
|
+
<View style={styles.changeActions}>
|
|
192
|
+
{props.onReject ? <Button title={props.rejectLabel ?? loc.reject} color="muted" onPress={props.onReject} /> : null}
|
|
193
|
+
{props.onAccept ? (
|
|
194
|
+
<Button title={props.acceptLabel ?? loc.accept} color="secondary" disabled={props.acceptDisabled} onPress={props.onAccept} />
|
|
195
|
+
) : null}
|
|
93
196
|
</View>
|
|
94
197
|
) : null}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
198
|
+
</View>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The change's SUBJECT — the section heading; a stack of changes skims by
|
|
203
|
+
* subject. Provenance goes at the section's BOTTOM (`<Sources>` before the
|
|
204
|
+
* actions), never crowded into the heading. */
|
|
205
|
+
export function ChangeLabel({ children }: { children: ReactNode }) {
|
|
206
|
+
return (
|
|
207
|
+
<View style={styles.labelBand}>
|
|
208
|
+
<Text size="sm" weight="semibold" style={{ flexShrink: 1 }} numberOfLines={1}>
|
|
209
|
+
{children}
|
|
210
|
+
</Text>
|
|
211
|
+
</View>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The agent's optional reasoning — for a change that is not
|
|
216
|
+
* self-explanatory. A quiet hairline-quoted aside: present when it earns its
|
|
217
|
+
* place, never competing with the value decision. */
|
|
218
|
+
export function ChangeReasoning({ children }: { children: ReactNode }) {
|
|
219
|
+
return (
|
|
220
|
+
<View style={styles.reasoning}>
|
|
221
|
+
<Text size="sm" color="muted">
|
|
222
|
+
{children}
|
|
223
|
+
</Text>
|
|
224
|
+
</View>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The stacked FIELD container — the open form of the record grammar: use it
|
|
229
|
+
* directly when the section IS the record under review (no card chrome);
|
|
230
|
+
* `ChangeRecord` wraps the same container in the item card. */
|
|
231
|
+
export function ChangeFields({ children }: { children: ReactNode }) {
|
|
232
|
+
const kids = Children.toArray(children);
|
|
233
|
+
const withRules: ReactNode[] = [];
|
|
234
|
+
kids.forEach((k, i) => {
|
|
235
|
+
if (i > 0) withRules.push(<View key={`rule-${i}`} style={styles.fieldRule} />);
|
|
236
|
+
withRules.push(k);
|
|
237
|
+
});
|
|
238
|
+
return <View style={styles.fields}>{withRules}</View>;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface ChangeRecordProps {
|
|
242
|
+
/** Stable identity — registers in the review context like a `Change`. */
|
|
243
|
+
id: string;
|
|
244
|
+
/** "add" (+, green) · "remove" (−, red) · "edit" (±, neutral — only the
|
|
245
|
+
* changed fields, each with its own mini-diff via `before`). */
|
|
246
|
+
tone: "add" | "remove" | "edit";
|
|
247
|
+
/** The card header title (the item's name). */
|
|
248
|
+
title: string;
|
|
249
|
+
/** The record's fields — `ChangeField`s (or any inline content). */
|
|
250
|
+
children: ReactNode;
|
|
251
|
+
status?: ChangeStatus;
|
|
252
|
+
onAccept?: () => void;
|
|
253
|
+
onReject?: () => void;
|
|
254
|
+
onUndo?: () => void;
|
|
255
|
+
/** The collapsed one-liner after a decision. */
|
|
256
|
+
summary: string;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A whole RECORD as ONE bordered card — the right altitude for line items:
|
|
261
|
+
* the tinted header carries the +/±/− op for the entire record, the body is
|
|
262
|
+
* the same stacked field grammar (`ChangeFields`), and the card's ONE
|
|
263
|
+
* Keep/Drop pair sits bottom-right. The verb level follows the decision
|
|
264
|
+
* level: add/remove cards carry the card verbs and their fields none; an
|
|
265
|
+
* edit card carries NO card verbs — its fields decide. Decided → the same
|
|
266
|
+
* compact bordered row every decided change collapses to.
|
|
267
|
+
*/
|
|
268
|
+
export function ChangeRecord(props: ChangeRecordProps) {
|
|
269
|
+
const loc = useLoticsLocale().changeReview;
|
|
270
|
+
const status = props.status ?? "pending";
|
|
271
|
+
useRegisterEntry("ChangeRecord", props.id, status, props.onAccept);
|
|
272
|
+
|
|
273
|
+
if (status !== "pending") {
|
|
274
|
+
const kept = status === "accepted";
|
|
275
|
+
return (
|
|
276
|
+
<View style={styles.fieldCollapsed}>
|
|
277
|
+
<Icon name={kept ? "check" : "x"} size={15} color={kept ? colors.emerald[600] : colors.zinc[400]} />
|
|
278
|
+
<Text size="sm" numberOfLines={1} style={[{ flex: 1 }, kept ? null : styles.strike]}>
|
|
279
|
+
{props.summary}
|
|
98
280
|
</Text>
|
|
99
|
-
<
|
|
100
|
-
|
|
281
|
+
{props.onUndo ? <Button title={loc.undo} color="muted" onPress={props.onUndo} /> : null}
|
|
282
|
+
</View>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const mark =
|
|
286
|
+
props.tone === "add"
|
|
287
|
+
? { glyph: "+", word: loc.opAdd, color: colors.emerald[700], bg: colors.emerald[50], header: colors.emerald[100], border: colors.emerald[200] }
|
|
288
|
+
: props.tone === "remove"
|
|
289
|
+
? { glyph: "−", word: loc.opRemove, color: colors.red[600], bg: colors.red[50], header: colors.red[100], border: colors.red[200] }
|
|
290
|
+
: { glyph: "±", word: loc.opEdit, color: colors.zinc[600], bg: colors.white, header: colors.zinc[50], border: colors.border };
|
|
291
|
+
return (
|
|
292
|
+
<View style={[styles.recordCard, { backgroundColor: mark.bg, borderColor: mark.border }]}>
|
|
293
|
+
{/* The op reads on the WHOLE card — a quiet tone wash (50 body ·
|
|
294
|
+
100 header · 200 border); an edit stays white, its diffs carry it. */}
|
|
295
|
+
<View style={[styles.recordCardHeader, { backgroundColor: mark.header }]}>
|
|
296
|
+
<Text size="sm" weight="semibold" tabular style={{ color: mark.color, width: 14 }}>
|
|
297
|
+
{mark.glyph}
|
|
101
298
|
</Text>
|
|
299
|
+
<Text size="sm" weight="semibold" style={{ color: mark.color }}>
|
|
300
|
+
{mark.word}
|
|
301
|
+
</Text>
|
|
302
|
+
<Text size="sm" color="muted">·</Text>
|
|
303
|
+
<Text size="sm" weight="semibold" numberOfLines={1} style={{ flexShrink: 1 }}>
|
|
304
|
+
{props.title}
|
|
305
|
+
</Text>
|
|
306
|
+
</View>
|
|
307
|
+
<View style={styles.recordCardBody}>
|
|
308
|
+
{/* Plain 6px stack — the hairline rhythm belongs to the OPEN form;
|
|
309
|
+
inside a card the border already scopes the record. */}
|
|
310
|
+
<View style={styles.recordFieldStack}>{props.children}</View>
|
|
311
|
+
{props.onAccept || props.onReject ? (
|
|
312
|
+
<View style={styles.verbRow}>
|
|
313
|
+
{props.onReject ? <Button title={loc.reject} color="muted" onPress={props.onReject} /> : null}
|
|
314
|
+
{props.onAccept ? <Button title={loc.accept} color="secondary" onPress={props.onAccept} /> : null}
|
|
315
|
+
</View>
|
|
316
|
+
) : null}
|
|
102
317
|
</View>
|
|
103
318
|
</View>
|
|
104
319
|
);
|
|
105
320
|
}
|
|
106
321
|
|
|
322
|
+
export interface ChangeFieldCandidate {
|
|
323
|
+
value: string;
|
|
324
|
+
/** Where the candidate came from (a document name) — quiet meta on the row;
|
|
325
|
+
* deciding between values requires knowing which source said what. */
|
|
326
|
+
source?: string;
|
|
327
|
+
/** A one-line qualifier (a score, a reason) for ranked picks. */
|
|
328
|
+
description?: string;
|
|
329
|
+
/** The row the current value matches — the host computes it. */
|
|
330
|
+
selected?: boolean;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export interface ChangeFieldProps {
|
|
334
|
+
label: string;
|
|
335
|
+
/** The proposed value. Omit for a pure REMOVAL (`before` alone — the − band
|
|
336
|
+
* is the whole change). */
|
|
337
|
+
value?: string;
|
|
338
|
+
/** Present ⇒ the value is press-to-edit in place (an input swaps in). */
|
|
339
|
+
onChangeText?: (value: string) => void;
|
|
340
|
+
/** The record's CURRENT value — the − band above the value (the field's
|
|
341
|
+
* mini-diff). Omit for a field the record doesn't hold yet (an add). */
|
|
342
|
+
before?: string;
|
|
343
|
+
/** Per-field decision — the same tri-state as a `Change` (dropping a field
|
|
344
|
+
* narrows the update diff). */
|
|
345
|
+
status?: "pending" | "kept" | "dropped";
|
|
346
|
+
onKeep?: () => void;
|
|
347
|
+
onDrop?: () => void;
|
|
348
|
+
onUndo?: () => void;
|
|
349
|
+
/** Gate Keep until a precondition holds (an unresolved conflict). */
|
|
350
|
+
keepDisabled?: boolean;
|
|
351
|
+
/** Conflict candidates — full-width decision rows under the value; the
|
|
352
|
+
* value band is the READ-ONLY outcome (`valueReadOnly`). */
|
|
353
|
+
candidates?: ChangeFieldCandidate[];
|
|
354
|
+
onPickCandidate?: (candidate: ChangeFieldCandidate) => void;
|
|
355
|
+
/** Offer "type another value" as an explicit third option under the
|
|
356
|
+
* candidates; selecting it reveals an input. The host stores the text and
|
|
357
|
+
* treats it like any picked value. */
|
|
358
|
+
customValue?: string;
|
|
359
|
+
customSelected?: boolean;
|
|
360
|
+
onCustomValue?: (value: string) => void;
|
|
361
|
+
onCustomSelect?: () => void;
|
|
362
|
+
/** The field's quiet why — the hairline aside under the value. */
|
|
363
|
+
reasoning?: string;
|
|
364
|
+
/** Render the value as the read-only outcome band (a conflict): the + band
|
|
365
|
+
* shows the value — or the muted `placeholder` while it's still empty. */
|
|
366
|
+
valueReadOnly?: boolean;
|
|
367
|
+
placeholder?: string;
|
|
368
|
+
/** The one-line resolved value on the collapsed row after a decision.
|
|
369
|
+
* Falls back to `value`. */
|
|
370
|
+
summary?: string;
|
|
371
|
+
/** Replace the built-in value surface — `ChangeValueInput` (the diff-at-rest
|
|
372
|
+
* editor) or any input. */
|
|
373
|
+
children?: ReactNode;
|
|
374
|
+
accessibilityLabel?: string;
|
|
375
|
+
}
|
|
376
|
+
|
|
107
377
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* Keep / Drop; deciding COLLAPSES it to a preview with Undo. Where a
|
|
117
|
-
* `ReviewCard` reviews a SINGLE proposal, this is the BATCH engine.
|
|
378
|
+
* THE field of the record grammar — one proposed value inside `ChangeFields`.
|
|
379
|
+
* A CHANGED field reads like the field diff: the label line, the − band
|
|
380
|
+
* (when replacing/removing), the + value (editable, read-only on a conflict,
|
|
381
|
+
* or absent on a pure removal), conflict candidates + the type-another-value
|
|
382
|
+
* third option, the quiet reasoning — deciding for ITSELF via Keep/Drop.
|
|
383
|
+
* A plain field (no diff, no decision) is the inline label · value row,
|
|
384
|
+
* press-to-edit when `onChangeText` is given. Decided → ONE quiet collapsed
|
|
385
|
+
* row: mark · label → value · Undo.
|
|
118
386
|
*/
|
|
119
|
-
export function
|
|
387
|
+
export function ChangeField(props: ChangeFieldProps) {
|
|
120
388
|
const loc = useLoticsLocale().changeReview;
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
const total = props.items.length;
|
|
124
|
-
const statusFor = (item: T, i: number) => props.statusOf?.(item, i) ?? "pending";
|
|
125
|
-
const keptCount = props.items.filter((it, i) => statusFor(it, i) === "accepted").length;
|
|
126
|
-
const title = props.title ?? loc.title;
|
|
127
|
-
const L = {
|
|
128
|
-
undo: loc.undo,
|
|
129
|
-
applied: loc.applied,
|
|
130
|
-
discarded: loc.discarded,
|
|
131
|
-
keptCount: loc.keptCount,
|
|
132
|
-
...props.labels,
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
const summaryNote = props.summary ? (
|
|
136
|
-
<View style={styles.note}>
|
|
137
|
-
<Text size="sm" color="muted">
|
|
138
|
-
{props.summary}
|
|
139
|
-
</Text>
|
|
140
|
-
</View>
|
|
141
|
-
) : null;
|
|
142
|
-
|
|
143
|
-
const resolvedLine =
|
|
144
|
-
status !== "open" ? (
|
|
145
|
-
<Text size="xs" color="muted" weight="medium">
|
|
146
|
-
{status === "applied" ? L.applied : L.discarded}
|
|
147
|
-
</Text>
|
|
148
|
-
) : null;
|
|
389
|
+
const [editing, setEditing] = useState(false);
|
|
390
|
+
const status = props.status ?? "pending";
|
|
149
391
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
392
|
+
if (status !== "pending") {
|
|
393
|
+
const kept = status === "kept";
|
|
394
|
+
const resolved = props.summary ?? props.value;
|
|
153
395
|
return (
|
|
154
|
-
<View style={
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
396
|
+
<View style={styles.fieldCollapsed}>
|
|
397
|
+
<Icon name={kept ? "check" : "x"} size={15} color={kept ? colors.emerald[600] : colors.zinc[400]} />
|
|
398
|
+
<View style={{ flex: 1, flexDirection: "row", alignItems: "baseline", gap: 6, flexWrap: "wrap" }}>
|
|
399
|
+
<Text size="sm" weight="medium" numberOfLines={1} style={kept ? null : styles.strike}>
|
|
400
|
+
{props.label}
|
|
158
401
|
</Text>
|
|
159
|
-
{
|
|
160
|
-
<Text size="
|
|
161
|
-
{
|
|
402
|
+
{kept && resolved ? (
|
|
403
|
+
<Text size="sm" color="muted" numberOfLines={1} style={{ flexShrink: 1 }}>
|
|
404
|
+
{resolved}
|
|
162
405
|
</Text>
|
|
163
406
|
) : null}
|
|
164
407
|
</View>
|
|
165
|
-
{
|
|
408
|
+
{props.onUndo ? <Button title={loc.undo} color="muted" onPress={props.onUndo} /> : null}
|
|
409
|
+
</View>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
166
412
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
<Button title={props.acceptLabel ?? loc.accept} color="secondary" onPress={() => props.onAcceptItem?.(i)} />
|
|
180
|
-
</View>
|
|
181
|
-
</View>
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
const kept = st === "accepted";
|
|
185
|
-
return (
|
|
186
|
-
<View key={props.getKey(item, i)} style={[styles.decidedRow, kept ? null : styles.decidedDrop]}>
|
|
187
|
-
<Icon name={kept ? "check" : "x"} size={15} color={kept ? colors.emerald[600] : colors.zinc[400]} />
|
|
188
|
-
<View style={{ flex: 1 }}>
|
|
189
|
-
{props.renderSummary ? (
|
|
190
|
-
props.renderSummary(item, kept, i)
|
|
191
|
-
) : (
|
|
192
|
-
<Text size="sm" color={kept ? "default" : "muted"} numberOfLines={1} style={kept ? null : styles.strike}>
|
|
193
|
-
{props.getTitle?.(item, i) ?? ""}
|
|
194
|
-
</Text>
|
|
195
|
-
)}
|
|
196
|
-
</View>
|
|
197
|
-
{props.onUndoItem ? (
|
|
198
|
-
<Button title={L.undo} color="muted" onPress={() => props.onUndoItem?.(i)} />
|
|
199
|
-
) : null}
|
|
200
|
-
</View>
|
|
201
|
-
);
|
|
202
|
-
})}
|
|
413
|
+
const isRemoval = props.before != null && !props.value && !props.onChangeText && !props.candidates && props.children == null;
|
|
414
|
+
if (props.before != null || props.candidates || props.reasoning || props.onKeep || props.onDrop || props.children != null || props.valueReadOnly) {
|
|
415
|
+
return (
|
|
416
|
+
<View style={styles.fieldDiff}>
|
|
417
|
+
<Text size="sm" weight="medium" numberOfLines={1}>
|
|
418
|
+
{props.label}
|
|
419
|
+
</Text>
|
|
420
|
+
{/* The agent's why sits under the label — consistent with the
|
|
421
|
+
record-level ChangeReasoning under its heading. */}
|
|
422
|
+
{props.reasoning ? (
|
|
423
|
+
<View style={styles.reasoning}>
|
|
424
|
+
<Text size="sm" color="muted">{props.reasoning}</Text>
|
|
203
425
|
</View>
|
|
204
|
-
)}
|
|
426
|
+
) : null}
|
|
427
|
+
{props.before != null ? <ChangeBand tone="remove">{props.before}</ChangeBand> : null}
|
|
428
|
+
{!isRemoval
|
|
429
|
+
? (props.children ??
|
|
430
|
+
(props.valueReadOnly || !props.onChangeText ? (
|
|
431
|
+
<ChangeBand tone="add">
|
|
432
|
+
{props.value ? (
|
|
433
|
+
props.value
|
|
434
|
+
) : (
|
|
435
|
+
<Text size="sm" color="muted" style={{ flexShrink: 1 }}>
|
|
436
|
+
{props.placeholder ?? ""}
|
|
437
|
+
</Text>
|
|
438
|
+
)}
|
|
439
|
+
</ChangeBand>
|
|
440
|
+
) : (
|
|
441
|
+
<ChangeValueInput
|
|
442
|
+
value={props.value ?? ""}
|
|
443
|
+
onChangeText={props.onChangeText}
|
|
444
|
+
placeholder={props.placeholder}
|
|
445
|
+
accessibilityLabel={props.accessibilityLabel ?? props.label}
|
|
446
|
+
/>
|
|
447
|
+
)))
|
|
448
|
+
: null}
|
|
449
|
+
{props.candidates && props.candidates.length > 0 ? (
|
|
450
|
+
<View style={styles.candidateList}>
|
|
451
|
+
{props.candidates.map((c, i) => (
|
|
452
|
+
<CardSelectItem
|
|
453
|
+
key={`${c.value}-${i}`}
|
|
454
|
+
onPress={() => props.onPickCandidate?.(c)}
|
|
455
|
+
selected={c.selected}
|
|
456
|
+
accessibilityLabel={`${props.label}: ${c.value}`}
|
|
457
|
+
style={styles.candidateItem}
|
|
458
|
+
>
|
|
459
|
+
<Text size="sm" weight="medium" tabular numberOfLines={2} style={{ flexShrink: 1 }}>
|
|
460
|
+
{c.value}
|
|
461
|
+
</Text>
|
|
462
|
+
</CardSelectItem>
|
|
463
|
+
))}
|
|
464
|
+
{props.onCustomValue ? (
|
|
465
|
+
props.customSelected ? (
|
|
466
|
+
<TextInputField
|
|
467
|
+
value={props.customValue ?? ""}
|
|
468
|
+
onChangeText={props.onCustomValue}
|
|
469
|
+
placeholder={loc.customValue}
|
|
470
|
+
accessibilityLabel={`${props.label}: ${loc.customValue}`}
|
|
471
|
+
autoFocus
|
|
472
|
+
/>
|
|
473
|
+
) : (
|
|
474
|
+
<CardSelectItem
|
|
475
|
+
onPress={() => props.onCustomSelect?.()}
|
|
476
|
+
accessibilityLabel={`${props.label}: ${loc.customValue}`}
|
|
477
|
+
style={styles.candidateItem}
|
|
478
|
+
>
|
|
479
|
+
<Text size="sm" color="muted">{loc.customValue}</Text>
|
|
480
|
+
</CardSelectItem>
|
|
481
|
+
)
|
|
482
|
+
) : null}
|
|
483
|
+
</View>
|
|
484
|
+
) : null}
|
|
485
|
+
{props.onKeep || props.onDrop ? (
|
|
486
|
+
<View style={styles.verbRow}>
|
|
487
|
+
{props.onDrop ? <Button title={loc.reject} color="muted" onPress={props.onDrop} /> : null}
|
|
488
|
+
{props.onKeep ? <Button title={loc.accept} color="secondary" disabled={props.keepDisabled} onPress={props.onKeep} /> : null}
|
|
489
|
+
</View>
|
|
490
|
+
) : null}
|
|
205
491
|
</View>
|
|
206
492
|
);
|
|
207
493
|
}
|
|
208
494
|
|
|
209
|
-
//
|
|
495
|
+
// A plain field: the inline-record row — DetailRow metrics (the record
|
|
496
|
+
// template's labelWidth 130 / minHeight 40); the value is press-to-edit
|
|
497
|
+
// when `onChangeText` is given.
|
|
498
|
+
// Rest and edit share ONE constant box (the full value column at 40px):
|
|
499
|
+
// editing swaps the Text for a borderless input with identical metrics.
|
|
500
|
+
const valueBlock =
|
|
501
|
+
editing && props.onChangeText ? (
|
|
502
|
+
<View style={[styles.fieldPress, styles.fieldPressHovered]}>
|
|
503
|
+
<RNTextInput
|
|
504
|
+
value={props.value}
|
|
505
|
+
onChangeText={props.onChangeText}
|
|
506
|
+
accessibilityLabel={props.accessibilityLabel ?? props.label}
|
|
507
|
+
autoFocus
|
|
508
|
+
onBlur={() => setEditing(false)}
|
|
509
|
+
onSubmitEditing={() => setEditing(false)}
|
|
510
|
+
style={styles.valueInput}
|
|
511
|
+
/>
|
|
512
|
+
</View>
|
|
513
|
+
) : (
|
|
514
|
+
<Text size="sm" weight="medium" tabular numberOfLines={1}>
|
|
515
|
+
{props.value}
|
|
516
|
+
</Text>
|
|
517
|
+
);
|
|
518
|
+
// The label names the SUBJECT of a decision — full color, never muted
|
|
519
|
+
// (DetailRow's muted label is the passive record-page convention).
|
|
210
520
|
return (
|
|
211
|
-
<View style={
|
|
212
|
-
<Text size="
|
|
213
|
-
{
|
|
521
|
+
<View style={styles.fieldPlainRow}>
|
|
522
|
+
<Text size="sm" weight="medium" style={styles.fieldPlainLabel} numberOfLines={1}>
|
|
523
|
+
{props.label}
|
|
214
524
|
</Text>
|
|
215
|
-
{
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
525
|
+
<View style={{ flex: 1 }}>
|
|
526
|
+
{!editing && props.onChangeText ? (
|
|
527
|
+
<FocusRingPressable
|
|
528
|
+
onPress={() => setEditing(true)}
|
|
529
|
+
accessibilityRole="button"
|
|
530
|
+
accessibilityLabel={props.accessibilityLabel ?? props.label}
|
|
531
|
+
style={(state: { hovered: boolean }) => [styles.fieldPress, state.hovered ? styles.fieldPressHovered : null]}
|
|
532
|
+
>
|
|
533
|
+
{valueBlock}
|
|
534
|
+
</FocusRingPressable>
|
|
535
|
+
) : (
|
|
536
|
+
valueBlock
|
|
537
|
+
)}
|
|
222
538
|
</View>
|
|
223
|
-
{resolvedLine}
|
|
224
539
|
</View>
|
|
225
540
|
);
|
|
226
541
|
}
|
|
227
542
|
|
|
543
|
+
export interface ChangeBandProps {
|
|
544
|
+
/** "add" = the green + band (incoming); "remove" = the red − band. */
|
|
545
|
+
tone: "add" | "remove";
|
|
546
|
+
/** Clamp string content to N lines. Default UNCLAMPED — a review must show
|
|
547
|
+
* the whole value being decided (prose edits included). */
|
|
548
|
+
numberOfLines?: number;
|
|
549
|
+
children: ReactNode;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** The raw diff BAND — a light tinted row with the aligned +/− marker. The
|
|
553
|
+
* pieces `ChangeField`/`ChangeValueInput` are built from it; reach for it
|
|
554
|
+
* directly for record-scale strokes (a removed record's one-line summary)
|
|
555
|
+
* or any custom body. */
|
|
556
|
+
export function ChangeBand(props: ChangeBandProps) {
|
|
557
|
+
const add = props.tone === "add";
|
|
558
|
+
return (
|
|
559
|
+
<View style={styles.diffBlock(add ? colors.emerald[50] : colors.red[50])}>
|
|
560
|
+
<Text size="sm" weight="medium" tabular style={styles.marker(add ? colors.emerald[700] : colors.red[600])}>
|
|
561
|
+
{add ? "+" : "−"}
|
|
562
|
+
</Text>
|
|
563
|
+
{typeof props.children === "string" ? (
|
|
564
|
+
<Text size="sm" tabular numberOfLines={props.numberOfLines} style={{ flexShrink: 1 }}>
|
|
565
|
+
{props.children}
|
|
566
|
+
</Text>
|
|
567
|
+
) : (
|
|
568
|
+
props.children
|
|
569
|
+
)}
|
|
570
|
+
</View>
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export interface ChangeValueInputProps {
|
|
575
|
+
value: string;
|
|
576
|
+
onChangeText?: (value: string) => void;
|
|
577
|
+
/** Shown (muted) at rest while the value is empty — an undecided conflict. */
|
|
578
|
+
placeholder?: string;
|
|
579
|
+
/** A fixed suffix (a unit — "pcs", "EUR"): renders after the editable core
|
|
580
|
+
* and never enters the input. Type the number, not the unit. */
|
|
581
|
+
unit?: string;
|
|
582
|
+
accessibilityLabel: string;
|
|
583
|
+
}
|
|
584
|
+
|
|
228
585
|
/**
|
|
229
|
-
* The
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
* It reads the same `items` + `statusOf` the list does, so it owns Accept-all
|
|
233
|
-
* and the apply-disabled-at-0-kept logic — the host never re-derives them.
|
|
234
|
-
* Renders a right-aligned button row (no divider — the footer container or an
|
|
235
|
-
* inline wrapper provides the chrome); self-hides once `status` isn't open.
|
|
586
|
+
* The proposed value as a PROPER DIFF at rest — the green `+` line — that
|
|
587
|
+
* becomes a real input on press. The review scans like a diff; editing is one
|
|
588
|
+
* click away. Rest and edit states share one height (no layout shift).
|
|
236
589
|
*/
|
|
237
|
-
export function
|
|
590
|
+
export function ChangeValueInput(props: ChangeValueInputProps) {
|
|
591
|
+
const [editing, setEditing] = useState(false);
|
|
592
|
+
const editable = props.onChangeText != null;
|
|
593
|
+
|
|
594
|
+
const marker = (
|
|
595
|
+
<Text size="sm" weight="medium" tabular style={styles.marker(colors.emerald[700])}>
|
|
596
|
+
+
|
|
597
|
+
</Text>
|
|
598
|
+
);
|
|
599
|
+
const unit = props.unit ? (
|
|
600
|
+
<Text size="sm" color="muted" tabular>
|
|
601
|
+
{props.unit}
|
|
602
|
+
</Text>
|
|
603
|
+
) : null;
|
|
604
|
+
|
|
605
|
+
// Editing happens INSIDE the band — the container never changes (no width /
|
|
606
|
+
// font / height shift): the value Text swaps for a borderless input with
|
|
607
|
+
// IDENTICAL type metrics; the unit stays fixed outside the editable core.
|
|
608
|
+
if (editing && editable) {
|
|
609
|
+
return (
|
|
610
|
+
<View style={[styles.valueRest, styles.valueRestHovered]}>
|
|
611
|
+
{marker}
|
|
612
|
+
<RNTextInput
|
|
613
|
+
value={props.value}
|
|
614
|
+
onChangeText={props.onChangeText}
|
|
615
|
+
placeholder={props.placeholder}
|
|
616
|
+
accessibilityLabel={props.accessibilityLabel}
|
|
617
|
+
autoFocus
|
|
618
|
+
onBlur={() => setEditing(false)}
|
|
619
|
+
onSubmitEditing={() => setEditing(false)}
|
|
620
|
+
style={styles.valueInput}
|
|
621
|
+
/>
|
|
622
|
+
{unit}
|
|
623
|
+
</View>
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const content = (
|
|
628
|
+
<>
|
|
629
|
+
{marker}
|
|
630
|
+
{props.value ? (
|
|
631
|
+
<Text size="sm" weight="medium" tabular numberOfLines={2} style={{ flexShrink: 1 }}>
|
|
632
|
+
{props.value}
|
|
633
|
+
</Text>
|
|
634
|
+
) : (
|
|
635
|
+
<Text size="sm" color="muted" style={{ flexShrink: 1 }}>
|
|
636
|
+
{props.placeholder ?? ""}
|
|
637
|
+
</Text>
|
|
638
|
+
)}
|
|
639
|
+
{unit}
|
|
640
|
+
{props.value && !props.unit ? null : <View style={{ flex: 1 }} />}
|
|
641
|
+
</>
|
|
642
|
+
);
|
|
643
|
+
if (!editable) {
|
|
644
|
+
return <View style={styles.valueRest}>{content}</View>;
|
|
645
|
+
}
|
|
646
|
+
return (
|
|
647
|
+
<FocusRingPressable
|
|
648
|
+
onPress={() => setEditing(true)}
|
|
649
|
+
accessibilityRole="button"
|
|
650
|
+
accessibilityLabel={props.accessibilityLabel}
|
|
651
|
+
style={(state: { hovered: boolean }) => [styles.valueRest, state.hovered ? styles.valueRestHovered : null]}
|
|
652
|
+
>
|
|
653
|
+
{content}
|
|
654
|
+
</FocusRingPressable>
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** The collapsed one-line content a decided `Change` shows. Compose text or
|
|
659
|
+
* anything row-sized; the frame (status mark + Undo) is the Change's. */
|
|
660
|
+
export function ChangeSummary({ children }: { children: ReactNode }) {
|
|
661
|
+
return typeof children === "string" ? (
|
|
662
|
+
<Text size="sm" numberOfLines={1}>
|
|
663
|
+
{children}
|
|
664
|
+
</Text>
|
|
665
|
+
) : (
|
|
666
|
+
<>{children}</>
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export interface ChangeReviewActionsProps {
|
|
671
|
+
onApply?: () => void;
|
|
672
|
+
onDiscard?: () => void;
|
|
673
|
+
/** Keep-all presses every pending entry's own `onAccept`. Rendered only
|
|
674
|
+
* when at least one registered entry is acceptable (or `onAcceptAll`). */
|
|
675
|
+
showAcceptAll?: boolean;
|
|
676
|
+
/** Host-owned Keep-all — replaces the context-derived handler ENTIRELY
|
|
677
|
+
* (for decisions the registry can't see: field-level state, unresolved
|
|
678
|
+
* conflicts). With it, the button renders even when no registered entry
|
|
679
|
+
* is acceptable. */
|
|
680
|
+
onAcceptAll?: () => void;
|
|
681
|
+
applyLabel?: string;
|
|
682
|
+
discardLabel?: string;
|
|
683
|
+
acceptAllLabel?: string;
|
|
684
|
+
/** Disable Apply below this many kept (default 1 when any entry carries
|
|
685
|
+
* accept/reject; pass 0 for a whole-set review that always applies). */
|
|
686
|
+
minKept?: number;
|
|
687
|
+
/** Host-level gate — for state the registry can't see (field inclusion, an
|
|
688
|
+
* unresolved conflict still on its placeholder). ORs with the kept gate. */
|
|
689
|
+
applyDisabled?: boolean;
|
|
690
|
+
/** The host's committing state — a real apply is an async write. */
|
|
691
|
+
applyLoading?: boolean;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* The commit bar — place it where commit bars belong (a `DialogFooter`, a
|
|
696
|
+
* `DrawerFooter`, inline under the list). Reads the registered entries, so
|
|
697
|
+
* Keep-all and the apply-disabled-at-0-kept logic never live in the host.
|
|
698
|
+
*/
|
|
699
|
+
export function ChangeReviewActions(props: ChangeReviewActionsProps) {
|
|
238
700
|
const loc = useLoticsLocale().changeReview;
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
if (statusFor(it, i) === "pending") props.onAcceptItem?.(i);
|
|
246
|
-
});
|
|
701
|
+
const { entries } = useReviewCtx("ChangeReviewActions");
|
|
702
|
+
const all = [...entries.values()];
|
|
703
|
+
const acceptable = all.filter((e) => e.accept != null);
|
|
704
|
+
const kept = all.filter((e) => e.status === "accepted").length;
|
|
705
|
+
const pendingAccepts = acceptable.filter((e) => e.status === "pending");
|
|
706
|
+
const minKept = props.minKept ?? (acceptable.length > 0 ? 1 : 0);
|
|
247
707
|
return (
|
|
248
708
|
<View style={styles.actionsRow}>
|
|
249
|
-
{
|
|
709
|
+
{props.showAcceptAll !== false && (props.onAcceptAll != null || acceptable.length > 0) ? (
|
|
710
|
+
<Button
|
|
711
|
+
title={props.acceptAllLabel ?? loc.acceptAll}
|
|
712
|
+
color="secondary"
|
|
713
|
+
disabled={props.onAcceptAll == null && pendingAccepts.length === 0}
|
|
714
|
+
onPress={props.onAcceptAll ?? (() => pendingAccepts.forEach((e) => e.accept?.()))}
|
|
715
|
+
/>
|
|
716
|
+
) : null}
|
|
717
|
+
{acceptable.length > 0 ? (
|
|
718
|
+
<Text size="xs" color="muted" tabular>
|
|
719
|
+
{loc.keptCount(kept, acceptable.length)}
|
|
720
|
+
</Text>
|
|
721
|
+
) : null}
|
|
250
722
|
<View style={{ flex: 1 }} />
|
|
251
723
|
{props.onDiscard ? <Button title={props.discardLabel ?? loc.discard} color="muted" onPress={props.onDiscard} /> : null}
|
|
252
724
|
{props.onApply ? (
|
|
253
725
|
<Button
|
|
254
|
-
title={props.applyLabel ??
|
|
726
|
+
title={props.applyLabel ?? loc.apply}
|
|
255
727
|
color="primary"
|
|
256
|
-
disabled={
|
|
728
|
+
disabled={props.applyDisabled === true || kept < minKept}
|
|
729
|
+
loading={props.applyLoading}
|
|
257
730
|
onPress={props.onApply}
|
|
258
731
|
/>
|
|
259
732
|
) : null}
|
|
@@ -263,41 +736,64 @@ export function ChangeReviewActions<T>(props: ChangeReviewActionsProps<T>) {
|
|
|
263
736
|
|
|
264
737
|
const styles = {
|
|
265
738
|
...StyleSheet.create({
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
},
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
739
|
+
stack: { gap: 10 },
|
|
740
|
+
header: { flexDirection: "row", alignItems: "center", gap: 8, paddingBottom: 2 },
|
|
741
|
+
// An OPEN section — no card box; the divider between changes is the only rule.
|
|
742
|
+
changeSection: { gap: 12, paddingVertical: 4 },
|
|
743
|
+
divider: { height: 1, backgroundColor: colors.zinc[100] },
|
|
744
|
+
// The heading line: subject left at full weight, meta right. No border —
|
|
745
|
+
// the subject's weight carries the hierarchy.
|
|
746
|
+
labelBand: { flexDirection: "row", alignItems: "center", gap: 8, minHeight: 24 },
|
|
747
|
+
changeBody: { gap: 8 },
|
|
748
|
+
changeActions: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 },
|
|
749
|
+
// A DECIDED change: the same open row, settled and quiet.
|
|
750
|
+
decidedRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 10, minHeight: 44 },
|
|
751
|
+
summaryFallback: { opacity: 0.7 },
|
|
752
|
+
reasoning: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
|
|
753
|
+
fields: { gap: 12 },
|
|
754
|
+
recordFieldStack: { gap: 6 },
|
|
755
|
+
fieldPlainRow: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40 },
|
|
756
|
+
fieldPlainLabel: { width: 130 },
|
|
757
|
+
fieldRule: { height: 1, backgroundColor: colors.zinc[100] },
|
|
758
|
+
fieldDiff: { gap: 6, paddingVertical: 4 },
|
|
759
|
+
recordCard: { borderWidth: 1, borderColor: colors.border, borderRadius: 10, backgroundColor: colors.white, overflow: "hidden" },
|
|
760
|
+
recordCardHeader: { flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 12, paddingVertical: 9, minHeight: 38 },
|
|
761
|
+
recordCardBody: { padding: 12, gap: 10 },
|
|
762
|
+
verbRow: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 },
|
|
763
|
+
fieldPress: { borderRadius: 8, borderWidth: 1, borderColor: "transparent", paddingHorizontal: 8, marginHorizontal: -8, minHeight: 40, justifyContent: "center", alignSelf: "stretch" },
|
|
764
|
+
fieldPressHovered: { borderColor: colors.border },
|
|
765
|
+
fieldCollapsed: { flexDirection: "row", alignItems: "center", gap: 10, minHeight: 44, borderWidth: 1, borderColor: colors.border, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 6, backgroundColor: colors.white },
|
|
766
|
+
candidateList: { gap: 8 },
|
|
767
|
+
candidateItem: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 10, paddingHorizontal: 12 },
|
|
295
768
|
strike: { textDecorationLine: "line-through" },
|
|
296
|
-
// The commit bar: a plain right-aligned row. The container (DialogFooter / an
|
|
297
|
-
// inline wrapper) owns any divider — the bar itself stays chrome-less so it
|
|
298
|
-
// nests cleanly in a footer without double borders. flex:1 fills the row.
|
|
299
769
|
actionsRow: { flexDirection: "row", alignItems: "center", gap: 8, flex: 1 },
|
|
770
|
+
// Reserves the input's height so rest ↔ edit never shifts the layout. The
|
|
771
|
+
// GitHub-diff idiom: a light GREEN band with dark text carries "incoming";
|
|
772
|
+
// an editable value hovers via its BORDER (the transparent edge reveals).
|
|
773
|
+
valueRest: { flexDirection: "row", alignItems: "center", gap: 8, minHeight: 40, borderRadius: 8, borderWidth: 1, borderColor: "transparent", paddingHorizontal: 10, backgroundColor: colors.emerald[50], alignSelf: "stretch" },
|
|
774
|
+
// The in-band editor: the SAME type metrics as the resting value Text —
|
|
775
|
+
// editing never shifts the band.
|
|
776
|
+
valueInput: {
|
|
777
|
+
flex: 1,
|
|
778
|
+
padding: 0,
|
|
779
|
+
borderWidth: 0,
|
|
780
|
+
backgroundColor: "transparent",
|
|
781
|
+
fontFamily: fontFamilyMedium,
|
|
782
|
+
fontSize: 14,
|
|
783
|
+
lineHeight: 20,
|
|
784
|
+
color: colors.zinc[900],
|
|
785
|
+
fontVariant: ["tabular-nums"],
|
|
786
|
+
},
|
|
787
|
+
valueRestHovered: { borderColor: colors.border },
|
|
300
788
|
}),
|
|
301
789
|
marker: (color: string) => ({ color, width: 12 }),
|
|
302
|
-
|
|
790
|
+
diffBlock: (bg: string) => ({
|
|
791
|
+
flexDirection: "row" as const,
|
|
792
|
+
alignItems: "center" as const,
|
|
793
|
+
gap: 8,
|
|
794
|
+
minHeight: 40,
|
|
795
|
+
borderRadius: 8,
|
|
796
|
+
paddingHorizontal: 10,
|
|
797
|
+
backgroundColor: bg,
|
|
798
|
+
}),
|
|
303
799
|
};
|