@lotics/ui 46.0.1 → 46.0.2
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/docs/data_entry.md +13 -0
- package/package.json +1 -1
- package/src/inline_edit.tsx +37 -6
- package/src/inline_markdown.tsx +1 -1
- package/src/inline_number_input.tsx +3 -1
- package/src/inline_text_input.tsx +1 -1
package/docs/data_entry.md
CHANGED
|
@@ -186,6 +186,19 @@ row the screen copied into `useState` (`app-sdk` `docs/mutations.md` § A read m
|
|
|
186
186
|
an in-flight write) — as does a write started by something other than an inline commit (a
|
|
187
187
|
dialog save, a debounced autosave).
|
|
188
188
|
|
|
189
|
+
**A field counts its own committed value as current until `value` catches up.** A caller updates
|
|
190
|
+
its stored copy only when the write RETURNS, so for the length of that round trip the `value` prop
|
|
191
|
+
still reads PRE-EDIT. Anything that re-opens the field in that window — a keyboard focus restore,
|
|
192
|
+
a second tap, a click into the neighbouring control — must not re-arm from that stale prop, because
|
|
193
|
+
the next blur would commit it as a fresh edit and the field would silently UNDO the edit it just
|
|
194
|
+
saved. `useInlineEdit` holds what it last sent and treats that as the current value for both the
|
|
195
|
+
re-arm and the "unchanged?" test, so a trailing commit of an already-sent value is a no-op; it also
|
|
196
|
+
renders that value (`edit.current`), so the old number never flashes back mid-write. The held copy
|
|
197
|
+
is dropped the moment `value` moves — whether it echoes the edit back or takes a different value
|
|
198
|
+
entirely — so it can never mask a change the field did not make. Nothing to wire up. This is not
|
|
199
|
+
theoretical: re-arming from the stale prop put a fee back to its pre-edit amount ~3s after an
|
|
200
|
+
operator corrected it, and the irreversible document minted seconds later billed the wrong figure.
|
|
201
|
+
|
|
189
202
|
### Keyboard entry — type → Tab → type
|
|
190
203
|
|
|
191
204
|
Bulk entry never needs the mouse. KEYBOARD focus (Tab / Shift+Tab) landing on a closed
|
package/package.json
CHANGED
package/src/inline_edit.tsx
CHANGED
|
@@ -37,6 +37,29 @@ export function useInlineEdit<T>(opts: {
|
|
|
37
37
|
const [draft, setDraftState] = useState<T>(value);
|
|
38
38
|
const [saving, setSaving] = useState(false);
|
|
39
39
|
const [error, setError] = useState<string | null>(null);
|
|
40
|
+
const eq = useCallback((a: T, b: T) => (equals ? equals(a, b) : a === b), [equals]);
|
|
41
|
+
|
|
42
|
+
// What this field last handed to `onSave`, held until `value` catches up to it.
|
|
43
|
+
//
|
|
44
|
+
// `value` is the caller's stored copy, and a caller does not update it until its
|
|
45
|
+
// write returns — so for the length of that round trip the prop still reads the
|
|
46
|
+
// PRE-EDIT value. Anything that re-opens the field in that window (a focus
|
|
47
|
+
// restore, a second tap) would otherwise seed the draft from a value the user
|
|
48
|
+
// has already replaced, and the next blur would commit it as if it were a fresh
|
|
49
|
+
// edit: the field silently undoes the edit it just saved. In an app where the
|
|
50
|
+
// field holds an amount that is about to be invoiced, that writes the wrong
|
|
51
|
+
// number to a document nobody can re-issue.
|
|
52
|
+
//
|
|
53
|
+
// `before` is what `value` read at commit time. The moment `value` differs from
|
|
54
|
+
// it the parent has moved on — whether by echoing this edit back or by taking a
|
|
55
|
+
// different value from somewhere else — and the pending copy is dropped rather
|
|
56
|
+
// than masking a change the field did not make.
|
|
57
|
+
const pending = useRef<{ committed: T; before: T } | null>(null);
|
|
58
|
+
const held = pending.current;
|
|
59
|
+
const awaitingEcho = held !== null && eq(held.before, value);
|
|
60
|
+
if (held !== null && !awaitingEcho) pending.current = null;
|
|
61
|
+
/** The value the field must behave as if it holds — see `pending`. */
|
|
62
|
+
const current = awaitingEcho && held !== null ? held.committed : value;
|
|
40
63
|
// Synchronous mirror of `editing`. The first exit (commit or cancel) flips it
|
|
41
64
|
// false; any trailing call — e.g. the blur that fires as Escape unmounts the
|
|
42
65
|
// input — is then a no-op, so the field commits/cancels exactly once.
|
|
@@ -58,11 +81,11 @@ export function useInlineEdit<T>(opts: {
|
|
|
58
81
|
}, []);
|
|
59
82
|
|
|
60
83
|
const begin = useCallback(() => {
|
|
61
|
-
setDraftState(
|
|
84
|
+
setDraftState(current);
|
|
62
85
|
setError(null);
|
|
63
86
|
active.current = true;
|
|
64
87
|
setEditing(true);
|
|
65
|
-
}, [
|
|
88
|
+
}, [current]);
|
|
66
89
|
|
|
67
90
|
const cancel = useCallback(() => {
|
|
68
91
|
if (!active.current) return;
|
|
@@ -75,8 +98,9 @@ export function useInlineEdit<T>(opts: {
|
|
|
75
98
|
async (next?: T) => {
|
|
76
99
|
if (!active.current) return;
|
|
77
100
|
const candidate = next === undefined ? draft : next;
|
|
78
|
-
|
|
79
|
-
|
|
101
|
+
// Against `current`, not `value`: re-committing what this field already
|
|
102
|
+
// saved is a no-op, not an edit back to the stored copy.
|
|
103
|
+
if (eq(candidate, current)) {
|
|
80
104
|
active.current = false;
|
|
81
105
|
setEditing(false);
|
|
82
106
|
return;
|
|
@@ -84,6 +108,7 @@ export function useInlineEdit<T>(opts: {
|
|
|
84
108
|
active.current = false;
|
|
85
109
|
setSaving(true);
|
|
86
110
|
setError(null);
|
|
111
|
+
pending.current = { committed: candidate, before: value };
|
|
87
112
|
try {
|
|
88
113
|
// Registered so an action pressed in the SAME gesture (the press that caused
|
|
89
114
|
// this blur) waits for the write instead of reading the pre-edit record —
|
|
@@ -92,6 +117,9 @@ export function useInlineEdit<T>(opts: {
|
|
|
92
117
|
await trackCommit((async () => onSave(candidate))());
|
|
93
118
|
setEditing(false);
|
|
94
119
|
} catch (e) {
|
|
120
|
+
// Nothing was stored, so there is no echo to wait for — drop the pending
|
|
121
|
+
// copy or the field would keep reporting a value the caller never took.
|
|
122
|
+
pending.current = null;
|
|
95
123
|
// Stay in edit mode so the entry isn't lost — show the error, re-arm.
|
|
96
124
|
setError(e instanceof Error && e.message ? e.message : labels.saveError);
|
|
97
125
|
active.current = true;
|
|
@@ -99,10 +127,13 @@ export function useInlineEdit<T>(opts: {
|
|
|
99
127
|
setSaving(false);
|
|
100
128
|
}
|
|
101
129
|
},
|
|
102
|
-
[draft, value, onSave,
|
|
130
|
+
[draft, value, current, onSave, eq, labels.saveError],
|
|
103
131
|
);
|
|
104
132
|
|
|
105
|
-
|
|
133
|
+
// `current` is what the field must SHOW as well as what it re-arms from: while a
|
|
134
|
+
// save is in flight the caller's `value` still reads pre-edit, and rendering that
|
|
135
|
+
// flashes the old number back at the operator mid-write.
|
|
136
|
+
return { editing, draft, setDraft, saving, error, begin, cancel, commit, current };
|
|
106
137
|
}
|
|
107
138
|
|
|
108
139
|
/**
|
package/src/inline_markdown.tsx
CHANGED
|
@@ -57,7 +57,7 @@ export function InlineMarkdown(props: InlineMarkdownProps) {
|
|
|
57
57
|
// ...but the ✓/✕ verbs still key off a REAL edit: at rest there is nothing
|
|
58
58
|
// to accept and nothing to revert.
|
|
59
59
|
editOpen={edit.editing}
|
|
60
|
-
display={
|
|
60
|
+
display={edit.current}
|
|
61
61
|
placeholder={placeholder}
|
|
62
62
|
onBegin={edit.begin}
|
|
63
63
|
controls="blur"
|
|
@@ -45,7 +45,9 @@ export function InlineNumberInput(props: InlineNumberInputProps) {
|
|
|
45
45
|
void edit.commit();
|
|
46
46
|
}, [controls, edit]);
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// `edit.current` rather than `value`: while a save is in flight the caller's
|
|
49
|
+
// copy still reads pre-edit, and showing that flashes the old number back.
|
|
50
|
+
const display = format ? format(edit.current) : edit.current == null ? "" : String(edit.current);
|
|
49
51
|
|
|
50
52
|
return (
|
|
51
53
|
<InlineEditFrame
|
|
@@ -158,7 +158,7 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
158
158
|
// accept and nothing to revert, so rendering them would both claim
|
|
159
159
|
// otherwise and hand the user two buttons that no-op.
|
|
160
160
|
editOpen={edit.editing}
|
|
161
|
-
display={
|
|
161
|
+
display={edit.current}
|
|
162
162
|
placeholder={placeholder}
|
|
163
163
|
onBegin={edit.begin}
|
|
164
164
|
controls={controls}
|