@lotics/ui 28.3.0 → 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.
@@ -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
+ }