@lotics/ui 28.3.1 → 29.2.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.
@@ -21,6 +21,7 @@ import {
21
21
  Linking,
22
22
  } from "react-native";
23
23
  import { isImageMimeType, isVideoMimeType, isAudioMimeType } from "./mime";
24
+ import { DiffMark, type DiffKind } from "./diff_mark";
24
25
 
25
26
  export const THUMBNAIL_SIZE = 96;
26
27
  export const COMPACT_THUMBNAIL_SIZE = 32;
@@ -86,14 +87,67 @@ interface FileThumbnailProps {
86
87
  /** Accessible name for the tile — what pressing it DOES, when the raw filename isn't
87
88
  * the useful name ("Xem chứng từ"). Defaults to `file.filename`. */
88
89
  accessibilityLabel?: string;
90
+ /**
91
+ * WHAT A PROPOSAL DOES TO THIS TILE — a corner mark for a document set under
92
+ * review, rendered as a grid rather than as a list.
93
+ *
94
+ * Top-LEFT because it is the one corner nothing else claims (remove sits
95
+ * top-right, selection bottom-right, the uploading scrim covers everything),
96
+ * and because it puts the mark where the row list already puts it.
97
+ *
98
+ * The disc rides a white RING: a tile's ground is an arbitrary photo, and a
99
+ * pale tint over a pale scan is not a mark, it is a smudge. `removed`
100
+ * additionally fades the tile BODY — the grid's way of saying a document is
101
+ * on its way off the record — while the mark itself stays at full strength,
102
+ * since a faded mark is the exact smudge the ring exists to prevent.
103
+ *
104
+ * Ignored on a COMPACT tile (≤ 32px): a 22px disc on a 32px tile is not a
105
+ * corner mark, it is the tile. A compact strip lives inside a row, and that
106
+ * row is where its change belongs.
107
+ *
108
+ * A grid expresses MEMBERSHIP (this document is arriving, that one is going)
109
+ * and expresses PAIRING badly: a 96px tile has nowhere to say which file a
110
+ * replacement supersedes. Use `FileRow` for a set where things are being
111
+ * replaced rather than added and removed.
112
+ */
113
+ diff?: DiffKind;
89
114
  }
90
115
 
116
+ /**
117
+ * The tile's diff mark — the same disc the rows use, on a white RING.
118
+ *
119
+ * The ring rather than the disc alone because a tile's ground is an arbitrary
120
+ * photo, and a pale tint over a pale scan is a smudge. It is circular rather
121
+ * than a rounded square: two shapes for one mark is exactly the inconsistency
122
+ * the disc exists to end.
123
+ */
124
+ function DiffCorner({ kind }: { kind: DiffKind }) {
125
+ if (kind === "unchanged") return null;
126
+ return (
127
+ <View style={diffCornerStyles.chip} pointerEvents="none">
128
+ <DiffMark kind={kind} />
129
+ </View>
130
+ );
131
+ }
132
+
133
+ const diffCornerStyles = StyleSheet.create({
134
+ chip: {
135
+ position: "absolute",
136
+ top: 4,
137
+ left: 4,
138
+ zIndex: 2,
139
+ backgroundColor: colors.white,
140
+ borderRadius: 999,
141
+ padding: 2,
142
+ },
143
+ });
144
+
91
145
  /**
92
146
  * Displays a file as a square thumbnail.
93
147
  * Works with any file source via DisplayFile interface.
94
148
  */
95
149
  export function FileThumbnail(props: FileThumbnailProps) {
96
- const { file, size, onPress, onLongPress, onRemove, selected, uploading, isTemplate, disablePress } = props;
150
+ const { file, size, onPress, onLongPress, onRemove, selected, uploading, isTemplate, disablePress, diff } = props;
97
151
  const accessibilityLabel = props.accessibilityLabel ?? file.filename;
98
152
  // A tile with no `onPress` still OPENS the file — a useful default for a bare
99
153
  // thumbnail in a list or a gallery. But a caller that said `disablePress`
@@ -106,13 +160,34 @@ export function FileThumbnail(props: FileThumbnailProps) {
106
160
  const rootStyle =
107
161
  size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
108
162
 
163
+ // A tile ≤ 32px is a badge in a strip, not a card: a 22px mark would swallow
164
+ // it. The row or cell holding the strip carries the change instead.
165
+ const compact = size !== undefined && size <= COMPACT_THUMBNAIL_SIZE;
166
+ const mark = diff === undefined || compact ? null : <DiffCorner kind={diff} />;
167
+
168
+ /**
169
+ * A document on its way OFF the record reads as leaving, and the fade says
170
+ * that across the whole tile — which is what a grid is scanned by.
171
+ *
172
+ * It wraps the BODY rather than the root on purpose. `opacity` cascades to
173
+ * the entire subtree, so on the root it faded the corner mark too, at exactly
174
+ * the moment the mark has the most to say. That is also the dimming
175
+ * `DiffMark`'s ink was darkened for; putting the mark inside it would have
176
+ * spent that margin for nothing.
177
+ */
178
+ const body = (node: React.ReactNode) =>
179
+ diff === "removed" ? <View style={styles.fadedBody}>{node}</View> : node;
180
+
109
181
  if (isImageMimeType(file.mimeType)) {
110
182
  return (
111
183
  <View style={rootStyle}>
112
- <ImageThumbnail file={file} size={size} accessibilityLabel={accessibilityLabel} onPress={press} onLongPress={onLongPress} />
184
+ {body(
185
+ <ImageThumbnail file={file} size={size} accessibilityLabel={accessibilityLabel} onPress={press} onLongPress={onLongPress} />,
186
+ )}
113
187
  {uploading && <UploadingOverlay size={size} />}
114
188
  {onRemove && <RemoveButton onPress={onRemove} />}
115
189
  {selected !== undefined && <SelectionOverlay selected={selected} />}
190
+ {mark}
116
191
  </View>
117
192
  );
118
193
  }
@@ -122,46 +197,52 @@ export function FileThumbnail(props: FileThumbnailProps) {
122
197
  if (size !== undefined && size <= COMPACT_THUMBNAIL_SIZE && mediaIcon === undefined) {
123
198
  return (
124
199
  <View style={rootStyle}>
125
- <DocumentBadge
126
- mimeType={file.mimeType}
127
- size={size}
128
- isTemplate={isTemplate}
129
- accessibilityLabel={accessibilityLabel}
130
- onPress={pressOrOpen}
131
- onLongPress={onLongPress}
132
- />
200
+ {body(
201
+ <DocumentBadge
202
+ mimeType={file.mimeType}
203
+ size={size}
204
+ isTemplate={isTemplate}
205
+ accessibilityLabel={accessibilityLabel}
206
+ onPress={pressOrOpen}
207
+ onLongPress={onLongPress}
208
+ />,
209
+ )}
133
210
  {uploading && <UploadingOverlay size={size} />}
134
211
  {onRemove && <RemoveButton onPress={onRemove} />}
212
+ {mark}
135
213
  </View>
136
214
  );
137
215
  }
138
216
 
139
217
  return (
140
218
  <View style={rootStyle}>
141
- {mediaIcon !== undefined ? (
142
- <MediaCard
143
- mimeType={file.mimeType}
144
- filename={file.filename}
145
- icon={mediaIcon}
146
- size={size}
147
- accessibilityLabel={accessibilityLabel}
148
- onPress={pressOrOpen}
149
- onLongPress={onLongPress}
150
- />
151
- ) : (
152
- <DocumentCard
153
- mimeType={file.mimeType}
154
- filename={file.filename}
155
- size={size}
156
- isTemplate={isTemplate}
157
- accessibilityLabel={accessibilityLabel}
158
- onPress={pressOrOpen}
159
- onLongPress={onLongPress}
160
- />
219
+ {body(
220
+ mediaIcon !== undefined ? (
221
+ <MediaCard
222
+ mimeType={file.mimeType}
223
+ filename={file.filename}
224
+ icon={mediaIcon}
225
+ size={size}
226
+ accessibilityLabel={accessibilityLabel}
227
+ onPress={pressOrOpen}
228
+ onLongPress={onLongPress}
229
+ />
230
+ ) : (
231
+ <DocumentCard
232
+ mimeType={file.mimeType}
233
+ filename={file.filename}
234
+ size={size}
235
+ isTemplate={isTemplate}
236
+ accessibilityLabel={accessibilityLabel}
237
+ onPress={pressOrOpen}
238
+ onLongPress={onLongPress}
239
+ />
240
+ ),
161
241
  )}
162
242
  {uploading && <UploadingOverlay size={size} />}
163
243
  {onRemove && <RemoveButton onPress={onRemove} />}
164
244
  {selected !== undefined && <SelectionOverlay selected={selected} />}
245
+ {mark}
165
246
  </View>
166
247
  );
167
248
  }
@@ -456,6 +537,8 @@ function SelectionOverlay({ selected }: { selected: boolean }) {
456
537
  // =============================================================================
457
538
 
458
539
  const styles = StyleSheet.create({
540
+ // Fills the tile so the fade covers the whole body and nothing reflows.
541
+ fadedBody: { width: "100%", height: "100%", opacity: 0.45 },
459
542
  documentCard: {
460
543
  borderRadius: 10,
461
544
  backgroundColor: colors.white,
@@ -15,6 +15,7 @@ import { View, Pressable, StyleSheet, type ViewStyle } from "react-native";
15
15
  import { Text } from "./text";
16
16
  import { colors } from "./colors";
17
17
  import { FileThumbnail, type DisplayFile, THUMBNAIL_SIZE, COMPACT_THUMBNAIL_SIZE } from "./file_thumbnail";
18
+ import { type DiffKind } from "./diff_mark";
18
19
  import { FOCUS_RING } from "./control_surface";
19
20
  import { useFocusRing } from "./use_focus_ring";
20
21
 
@@ -171,12 +172,22 @@ export interface FileThumbnailGridProps {
171
172
  disablePress?: boolean;
172
173
  /** Selected file IDs — renders a selection overlay on those thumbnails. */
173
174
  selectedIds?: ReadonlySet<string>;
175
+ /**
176
+ * What a proposal does to each file, keyed by id — a corner mark on the tile
177
+ * and a fade on anything being removed. Keyed the same way `selectedIds` is,
178
+ * so a grid under review is the SAME grid with one more map passed in.
179
+ *
180
+ * A grid says MEMBERSHIP well and PAIRING badly: there is nowhere on a 96px
181
+ * tile to name the file a replacement supersedes. A set where documents are
182
+ * being replaced rather than added and removed wants `FileRow`.
183
+ */
184
+ diffs?: ReadonlyMap<string, DiffKind>;
174
185
  onFilePress?: (file: DisplayFile) => void;
175
186
  onRemove?: (id: string) => void;
176
187
  }
177
188
 
178
189
  export function FileThumbnailGrid(props: FileThumbnailGridProps) {
179
- const { files, selectedIds, onFilePress, onRemove, disablePress, ...layout } = props;
190
+ const { files, selectedIds, diffs, onFilePress, onRemove, disablePress, ...layout } = props;
180
191
  return (
181
192
  <ThumbnailGrid
182
193
  {...layout}
@@ -187,6 +198,7 @@ export function FileThumbnailGrid(props: FileThumbnailGridProps) {
187
198
  <FileThumbnail
188
199
  file={file}
189
200
  size={size}
201
+ diff={diffs?.get(file.id)}
190
202
  onPress={disablePress || !onFilePress ? undefined : () => onFilePress(file)}
191
203
  // Withholding `onPress` is NOT enough: a tile with none falls back to
192
204
  // opening the file, so it stays a button and a tab stop. The prop has
package/src/finding.tsx CHANGED
@@ -47,9 +47,8 @@ export interface FindingProps {
47
47
  * observation, a briefing item. Good defaults (severity word, title, detail,
48
48
  * the PROMINENT metric, `Sources` chips) with a `children` slot for any
49
49
  * extra composition. Display-only: a finding informs the verdict the host
50
- * records; it decides nothing itself. Stack several most-severe first
51
- * inside a `ChangeReview` wrap each in a display-only `Change` (the family's
52
- * dividers apply); standalone, separate them yourself.
50
+ * records; it decides nothing itself. Stack several most-severe first,
51
+ * separated by a `Divider`.
53
52
  */
54
53
  export function Finding(props: FindingProps) {
55
54
  const words = useLoticsLocale().finding;
@@ -0,0 +1,58 @@
1
+ import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
2
+ import { type ReactNode } from "react";
3
+ import { CONTROL_RADIUS } from "./control_surface";
4
+ import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
5
+
6
+ export interface InlineSlotProps {
7
+ /** Whatever sits in the value column — a `DiffValue`, a chip, a pair. */
8
+ children: ReactNode;
9
+ style?: StyleProp<ViewStyle>;
10
+ }
11
+
12
+ /**
13
+ * ANY node placed on the INLINE-CONTROL GRID — the box an `Inline*` editor
14
+ * draws, minus the editor.
15
+ *
16
+ * A record's value column mixes editors with things that are not editors: a
17
+ * computed total, a synced value, a proposed change. The editors are 40px tall
18
+ * with 8px of horizontal padding inside a 1px border, and their text therefore
19
+ * sits 9px in from the cell edge and centred in a 40px band. Anything rendered
20
+ * raw beside them starts at the cell edge, 20px tall, at the top — so the value
21
+ * column quietly acquires TWO left edges and two baselines, and `DetailRow`'s
22
+ * label (which pads down to meet a control) agrees with one kind of row and not
23
+ * the other.
24
+ *
25
+ * That is not a cosmetic drift. The column is the only thing telling a reader
26
+ * these values belong to one record, and it stops reading as a column.
27
+ *
28
+ * `InlineStatic` is the STRING case of this and composes it. Reach for the slot
29
+ * directly when the value is a node — which a review surface always is, since a
30
+ * proposed value is a `DiffValue` sitting where its editor would be.
31
+ *
32
+ * NOT for a table cell: a `Table` sets its own row rhythm, and a 40px control
33
+ * band inside one only makes the rows taller.
34
+ */
35
+ export function InlineSlot({ children, style }: InlineSlotProps) {
36
+ return <View style={[styles.box, style]}>{children}</View>;
37
+ }
38
+
39
+ const styles = StyleSheet.create({
40
+ // Matches `inline_edit`'s view box exactly (height, radius, padding, 1px
41
+ // transparent border) so the content's baseline aligns with the editors —
42
+ // minus the FocusRingPressable, so it never reads as an interactive control.
43
+ box: {
44
+ minHeight: INLINE_CONTROL_HEIGHT,
45
+ borderRadius: CONTROL_RADIUS,
46
+ borderWidth: 1,
47
+ borderColor: "transparent",
48
+ paddingHorizontal: 8,
49
+ // The editors' vertical padding too, not just the horizontal. A single line
50
+ // hides its absence — the box is `minHeight` 40 and the text is ~20, so both
51
+ // sides land on 40 whether or not this exists — but an auto-growing input
52
+ // sizes itself to content PLUS its padding and border, so from the SECOND
53
+ // line on, a value without this sits ~14px shorter than the input that
54
+ // replaces it. Matching by coincidence at one line is not matching.
55
+ paddingVertical: 8,
56
+ justifyContent: "center",
57
+ },
58
+ });
@@ -1,8 +1,8 @@
1
- import { StyleSheet, View } from "react-native";
1
+ import { StyleSheet } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
- import { CONTROL_RADIUS } from "./control_surface";
5
- import { INLINE_CONTROL_HEIGHT, inlineValueTextStyle } from "./inline_edit";
4
+ import { inlineValueTextStyle } from "./inline_edit";
5
+ import { InlineSlot } from "./inline_slot";
6
6
 
7
7
  export interface InlineStaticProps {
8
8
  /** The value to show. Empty → `placeholder` (default "—"). */
@@ -45,7 +45,7 @@ export function InlineStatic(props: InlineStaticProps) {
45
45
  const isEmpty = value.length === 0;
46
46
  const display = isEmpty ? (placeholder ?? "—") : value;
47
47
  return (
48
- <View style={styles.box}>
48
+ <InlineSlot>
49
49
  <Text
50
50
  numberOfLines={multiline ? undefined : 1}
51
51
  tabular={tabular}
@@ -56,28 +56,10 @@ export function InlineStatic(props: InlineStaticProps) {
56
56
  >
57
57
  {display}
58
58
  </Text>
59
- </View>
59
+ </InlineSlot>
60
60
  );
61
61
  }
62
62
 
63
63
  const styles = StyleSheet.create({
64
- // Matches `inline_edit`'s view box exactly (height, radius, padding, 1px
65
- // transparent border) so the value's baseline aligns with the editors — minus
66
- // the FocusRingPressable, so it never reads as an interactive control.
67
- box: {
68
- minHeight: INLINE_CONTROL_HEIGHT,
69
- borderRadius: CONTROL_RADIUS,
70
- borderWidth: 1,
71
- borderColor: "transparent",
72
- paddingHorizontal: 8,
73
- // The editors' vertical padding too, not just the horizontal. A single line
74
- // hides its absence — the box is `minHeight` 40 and the text is ~20, so both
75
- // sides land on 40 whether or not this exists — but an auto-growing input
76
- // sizes itself to content PLUS its padding and border, so from the SECOND
77
- // line on, a value without this sits ~14px shorter than the input that
78
- // replaces it. Matching by coincidence at one line is not matching.
79
- paddingVertical: 8,
80
- justifyContent: "center",
81
- },
82
64
  placeholder: { color: colors.zinc[400] },
83
65
  });
package/src/locale.tsx CHANGED
@@ -94,27 +94,17 @@ export interface LoticsLocale {
94
94
  suggestionChip: { add: (label: string) => string; dismiss: string };
95
95
  /** `Confidence`: the full level phrase ("High confidence" …). */
96
96
  confidence: ConfidenceLabels;
97
- /** `Finding`: the severity badge word ("Critical" …). */
98
97
  /** `RemainderMeter`: the applied / remaining / over / exact captions. */
99
98
  remainderMeter: Required<RemainderMeterLabels>;
100
- /** `ChangeReview` (+ `ChangeReviewActions`): the review-card chrome the
101
- * Keep/Drop verdicts, Undo, the Applied/Discarded tags, the kept-counter,
102
- * the title, and the commit-bar Accept-all / Discard. */
99
+ /** `Finding`: the severity words + the comparison delta caption. */
103
100
  finding: FindingLabels;
104
- changeReview: {
105
- title: string;
106
- accept: string;
107
- reject: string;
108
- undo: string;
109
- keptCount: (kept: number, total: number) => string;
110
- acceptAll: string;
111
- discard: string;
112
- apply: string;
113
- recommended: string;
114
- customValue: string;
115
- opAdd: string;
116
- opEdit: string;
117
- opRemove: string;
101
+ /** `DiffMark`: what a proposal does to a row — the accessible word each mark
102
+ * announces, since the meaning may never rest on colour alone. */
103
+ diff: {
104
+ added: string;
105
+ changed: string;
106
+ removed: string;
107
+ unchanged: string;
118
108
  };
119
109
  /** `DateRangeFilterField` (and the `DateFilter` panel it wraps): presets,
120
110
  * from/to, the footer Clear/Done, the trigger placeholder, and the time-field
@@ -273,20 +263,11 @@ export const en: LoticsLocale = {
273
263
  exact: "Fully applied",
274
264
  },
275
265
  finding: { critical: "Critical", warning: "Warning", info: "Note", positive: "On track", difference: "Difference" },
276
- changeReview: {
277
- title: "Suggested edits",
278
- accept: "Keep",
279
- reject: "Drop",
280
- undo: "Undo",
281
- keptCount: (kept, total) => `${kept} of ${total} kept`,
282
- acceptAll: "Keep all",
283
- discard: "Discard",
284
- apply: "Apply",
285
- recommended: "AI pick",
286
- customValue: "Type another value",
287
- opAdd: "Add",
288
- opEdit: "Edit",
289
- opRemove: "Delete",
266
+ diff: {
267
+ added: "Added",
268
+ changed: "Changed",
269
+ removed: "Removed",
270
+ unchanged: "Unchanged",
290
271
  },
291
272
  dateRange: {
292
273
  year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM",
@@ -437,20 +418,11 @@ export const vi: LoticsLocale = {
437
418
  exact: "Đã phân bổ đủ",
438
419
  },
439
420
  finding: { critical: "Nghiêm trọng", warning: "Cảnh báo", info: "Ghi chú", positive: "Đúng tiến độ", difference: "Chênh lệch" },
440
- changeReview: {
441
- title: "Đề xuất chỉnh sửa",
442
- accept: "Giữ",
443
- reject: "Bỏ",
444
- undo: "Hoàn tác",
445
- keptCount: (kept, total) => `Đã giữ ${kept}/${total}`,
446
- acceptAll: "Giữ tất cả",
447
- discard: "Bỏ hết",
448
- apply: "Áp dụng",
449
- recommended: "AI đề xuất",
450
- customValue: "Nhập giá trị khác",
451
- opAdd: "Thêm",
452
- opEdit: "Sửa",
453
- opRemove: "Xóa",
421
+ diff: {
422
+ added: "Thêm mới",
423
+ changed: "Thay đổi",
424
+ removed: "Xóa",
425
+ unchanged: "Giữ nguyên",
454
426
  },
455
427
  dateRange: {
456
428
  year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH",
@@ -5,7 +5,7 @@ import { Icon, type IconName } from "./icon";
5
5
  import { Text, type HeadingLevel } from "./text";
6
6
 
7
7
  /**
8
- * Result header — the strip that opens the POST-SAVE RECEIPT. `ChangeReview` gates a proposal
8
+ * Result header — the strip that opens the POST-SAVE RECEIPT. A review surface gates a proposal
9
9
  * BEFORE it applies; this header opens the receipt for what a save-direct run
10
10
  * ALREADY CREATED. The receipt states the outcome and routes — it never edits
11
11
  * (correction happens through the record's ordinary verbs, on the record):
@@ -0,0 +1,115 @@
1
+ import { useCallback, useMemo, useState } from "react";
2
+
3
+ export type ChangeDecision = "pending" | "accepted" | "rejected";
4
+
5
+ /**
6
+ * THE REVIEW BOOKKEEPING, WITHOUT A SURFACE.
7
+ *
8
+ * Keeping which proposals are in and which are out is genuinely fiddly — the
9
+ * counter, keep-all, undo, the commit gate — and genuinely the same every time.
10
+ * What is NOT the same every time is where any of it renders. So this is a hook:
11
+ * the host gets the state machine and draws whatever its screen needs, instead
12
+ * of adopting a container to get the arithmetic.
13
+ *
14
+ * Decisions are stored as OVERRIDES against a default, never seeded from `ids`
15
+ * into state. Seeding would need an effect to keep in step, and `ids` is
16
+ * typically a `useMemo` over query rows — a new array every render, so the
17
+ * effect fires every render and sets state every time. (That exact loop has bit
18
+ * this codebase before: `Maximum update depth exceeded` in a settle drawer that
19
+ * seeded "everything ticked" from its rows.) Storing the exceptions makes the
20
+ * selection derived: nothing to seed, nothing to re-sync, and ids that come and
21
+ * go simply pick up the default.
22
+ */
23
+ export interface ChangeSet<Id extends string = string> {
24
+ status: (id: Id) => ChangeDecision;
25
+ accept: (id: Id) => void;
26
+ reject: (id: Id) => void;
27
+ /** Back to the default — the Undo on a decided row. */
28
+ undo: (id: Id) => void;
29
+ acceptAll: () => void;
30
+ rejectAll: () => void;
31
+ /** Every id back to the default. */
32
+ reset: () => void;
33
+ accepted: readonly Id[];
34
+ rejected: readonly Id[];
35
+ pending: readonly Id[];
36
+ keptCount: number;
37
+ total: number;
38
+ /** Nothing is left undecided — the usual gate on a commit button. */
39
+ settled: boolean;
40
+ }
41
+
42
+ export interface UseChangeSetOptions {
43
+ /**
44
+ * What an untouched proposal counts as. Default `accepted`: the operator
45
+ * drops the exceptions rather than approving each of eight identical lines,
46
+ * which is the difference between a review and a second round of data entry.
47
+ * Use `pending` when each change genuinely deserves its own verdict — and
48
+ * gate the commit on `settled`.
49
+ */
50
+ initial?: ChangeDecision;
51
+ }
52
+
53
+ export function useChangeSet<Id extends string = string>(
54
+ ids: readonly Id[],
55
+ options?: UseChangeSetOptions,
56
+ ): ChangeSet<Id> {
57
+ const initial = options?.initial ?? "accepted";
58
+ const [overrides, setOverrides] = useState<ReadonlyMap<Id, ChangeDecision>>(new Map());
59
+
60
+ const set = useCallback((id: Id, decision: ChangeDecision) => {
61
+ setOverrides((prev) => {
62
+ const next = new Map(prev);
63
+ next.set(id, decision);
64
+ return next;
65
+ });
66
+ }, []);
67
+
68
+ const undo = useCallback((id: Id) => {
69
+ setOverrides((prev) => {
70
+ if (!prev.has(id)) return prev;
71
+ const next = new Map(prev);
72
+ next.delete(id);
73
+ return next;
74
+ });
75
+ }, []);
76
+
77
+ const all = useCallback(
78
+ (decision: ChangeDecision) => setOverrides(new Map(ids.map((id) => [id, decision]))),
79
+ [ids],
80
+ );
81
+
82
+ const status = useCallback((id: Id) => overrides.get(id) ?? initial, [overrides, initial]);
83
+
84
+ const groups = useMemo(() => {
85
+ const accepted: Id[] = [];
86
+ const rejected: Id[] = [];
87
+ const pending: Id[] = [];
88
+ for (const id of ids) {
89
+ const s = overrides.get(id) ?? initial;
90
+ if (s === "accepted") accepted.push(id);
91
+ else if (s === "rejected") rejected.push(id);
92
+ else pending.push(id);
93
+ }
94
+ return { accepted, rejected, pending };
95
+ }, [ids, overrides, initial]);
96
+
97
+ return useMemo(
98
+ () => ({
99
+ status,
100
+ accept: (id: Id) => set(id, "accepted"),
101
+ reject: (id: Id) => set(id, "rejected"),
102
+ undo,
103
+ acceptAll: () => all("accepted"),
104
+ rejectAll: () => all("rejected"),
105
+ reset: () => setOverrides(new Map()),
106
+ accepted: groups.accepted,
107
+ rejected: groups.rejected,
108
+ pending: groups.pending,
109
+ keptCount: groups.accepted.length,
110
+ total: ids.length,
111
+ settled: groups.pending.length === 0,
112
+ }),
113
+ [status, set, undo, all, groups, ids.length],
114
+ );
115
+ }