@lotics/ui 46.0.1 → 46.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.
- package/docs/data_entry.md +13 -0
- package/docs/templates.md +14 -0
- package/package.json +1 -1
- package/src/brand_mark.tsx +31 -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/docs/templates.md
CHANGED
|
@@ -87,6 +87,20 @@ intended behaviour when the job calls for one — the line between a choice and
|
|
|
87
87
|
[composition.md](./composition.md) §"The form comes before the treatment", and it is drawn there
|
|
88
88
|
for this too.
|
|
89
89
|
|
|
90
|
+
**A surface that is a FILTERED VIEW of another surface is not a separate subject.** The same test
|
|
91
|
+
that separates a tab from a lens decides whether a screen should exist at all, one altitude up: if
|
|
92
|
+
its rows are another screen's rows with a condition applied, it is a lens on that screen and belongs
|
|
93
|
+
in its toolbar. The tell is a screen that reads as disconnected while every part of it looks
|
|
94
|
+
correct — a costs screen whose rows are the bank ledger filtered to outflows, an "overdue" screen
|
|
95
|
+
whose rows are the register filtered by a date. Each one duplicates a subject and then drifts from
|
|
96
|
+
it, because two surfaces over one set of rows grow two ways of writing to them.
|
|
97
|
+
|
|
98
|
+
**And nothing should answer a question another surface already owns.** A register that starts
|
|
99
|
+
computing the P&L is building a second answer to a question the dashboard is for — the two will
|
|
100
|
+
disagree the first time either changes, and the reader has no way to know which is right. Where a
|
|
101
|
+
screen genuinely needs a figure that lives elsewhere, it states the figure and points; it does not
|
|
102
|
+
re-derive it.
|
|
103
|
+
|
|
90
104
|
### App-screen shapes
|
|
91
105
|
|
|
92
106
|
**What earns a tab.** A band is a TAB when it changes what the screen IS — its columns, the act on
|
package/package.json
CHANGED
package/src/brand_mark.tsx
CHANGED
|
@@ -6,8 +6,19 @@ import { Svg, Path } from "react-native-svg";
|
|
|
6
6
|
* takes a generic `Icon` — a phone call, an in-person event, a website, an
|
|
7
7
|
* email that is not tied to one provider. Inventing a mark for those is how a
|
|
8
8
|
* byline ends up claiming a channel the record does not carry.
|
|
9
|
+
*
|
|
10
|
+
* A CHANNEL earns a mark; a PRODUCT does not. The line is whether a business
|
|
11
|
+
* reaches people through it — a post, a message, a campaign — or merely uses it
|
|
12
|
+
* to work. So the ad platforms belong here beside the social surfaces, while a
|
|
13
|
+
* mailbox or a drive does not, and the two are easy to confuse because the same
|
|
14
|
+
* company often sells both.
|
|
15
|
+
*
|
|
16
|
+
* Every mark is a single-path CC0 silhouette taken from simple-icons, with that
|
|
17
|
+
* project's own hex. Never hand-write one: a recalled path renders something
|
|
18
|
+
* plausible and slightly wrong, which is the one defect a reader spots instantly
|
|
19
|
+
* and a test suite never does.
|
|
9
20
|
*/
|
|
10
|
-
export const BRAND_NAMES = ["facebook", "zalo", "linkedin", "x", "google-meet"] as const;
|
|
21
|
+
export const BRAND_NAMES = ["facebook", "meta", "tiktok", "google-ads", "zalo", "linkedin", "x", "google-meet"] as const;
|
|
11
22
|
|
|
12
23
|
export type BrandName = (typeof BRAND_NAMES)[number];
|
|
13
24
|
|
|
@@ -39,6 +50,25 @@ interface Mark {
|
|
|
39
50
|
* in (`SwitchButton`'s `leading` slot).
|
|
40
51
|
*/
|
|
41
52
|
const MARKS: Record<BrandName, Mark> = {
|
|
53
|
+
/** The company and its ad platform. `facebook` is the consumer surface a post goes
|
|
54
|
+
* on; `meta` is what bills the ad spend, and a row about money names the
|
|
55
|
+
* platform that took it. */
|
|
56
|
+
"meta": {
|
|
57
|
+
color: "#0467DF",
|
|
58
|
+
path: "M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z",
|
|
59
|
+
},
|
|
60
|
+
/** Short video, and for a Vietnamese seller increasingly the channel that outsells
|
|
61
|
+
* the one they budget for. */
|
|
62
|
+
"tiktok": {
|
|
63
|
+
color: "#000000",
|
|
64
|
+
path: "M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z",
|
|
65
|
+
},
|
|
66
|
+
/** Search and shopping ads. Distinct from `google-meet`, which is a meeting rather
|
|
67
|
+
* than a spend. */
|
|
68
|
+
"google-ads": {
|
|
69
|
+
color: "#4285F4",
|
|
70
|
+
path: "M3.9998 22.9291C1.7908 22.9291 0 21.1383 0 18.9293s1.7908-3.9998 3.9998-3.9998 3.9998 1.7908 3.9998 3.9998-1.7908 3.9998-3.9998 3.9998zm19.4643-6.0004L15.4632 3.072C14.3586 1.1587 11.9121.5028 9.9988 1.6074S7.4295 5.1585 8.5341 7.0718l8.0009 13.8567c1.1046 1.9133 3.5511 2.5679 5.4644 1.4646 1.9134-1.1046 2.568-3.5511 1.4647-5.4644zM7.5137 4.8438L1.5645 15.1484A4.5 4.5 0 0 1 4 14.4297c2.5597-.0075 4.6248 2.1585 4.4941 4.7148l3.2168-5.5723-3.6094-6.25c-.4499-.7793-.6322-1.6394-.5878-2.4784z",
|
|
71
|
+
},
|
|
42
72
|
facebook: {
|
|
43
73
|
color: "#0866FF",
|
|
44
74
|
path: "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z",
|
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}
|