@lotics/ui 27.13.1 → 27.13.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 +31 -0
- package/docs/templates.md +3 -1
- package/examples/tpl_record.tsx +78 -65
- package/package.json +1 -1
- package/src/detail_row.tsx +21 -2
package/docs/data_entry.md
CHANGED
|
@@ -13,6 +13,7 @@ in [the templates](./templates.md) (`examples/tpl_*.tsx`).
|
|
|
13
13
|
| You're capturing… | Reach for | Why |
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| an EXISTING record's fields | [**Inline edit**](#inline-edit--the-preferred-way-to-edit-an-existing-record) (`Inline*`) | edit in place, no form mode |
|
|
16
|
+
| a DRAFT inside a `Dialog` (create / edit-then-save) | [**`FormField` / `FormTextInput`**](#a-dialog-is-a-draft--use-formfield-never-an-inline-editor), controlled | the dialog's action IS the commit; inline editors make everything derived from the draft lag by one blur |
|
|
16
17
|
| a value in a DENSE ROW / register `DataGrid` cell (a task's due, assignee, status) | the SAME `Inline*` editor in **`variant="bare"`** | one editor per value type; `bare` drops the resting frame (the grid already promises every cell edits) and fades the same border in on hover — there is no separate `*Cell` family |
|
|
17
18
|
| a brand-NEW record | **create-then-refine** (`tpl_record`: "New" is ONE CLICK → a fresh Draft; everything refines in place on the record surface) | nobody fills 5 sections in one sitting; the surface is the editor |
|
|
18
19
|
| a RELATED record (pick or make) | [**find-or-create**](#find-or-create--the-combobox-family-is-the-control) (`Combobox allowCustom`) | one control covers both |
|
|
@@ -41,6 +42,36 @@ the CONTROL COLUMN, the same left edge the user just filled, and inherits stacke
|
|
|
41
42
|
narrow containers. A right-floated button aligns to nothing; only OVERLAY footers
|
|
42
43
|
(`DialogFooter`/`DrawerFooter`) right-align. Worked: `tpl_record`'s Delivery receipt.
|
|
43
44
|
|
|
45
|
+
### A dialog is a draft — use `FormField`, never an `Inline*` editor
|
|
46
|
+
|
|
47
|
+
An inline editor SELF-PERSISTS: it holds a local draft and reports it on blur (or Enter). A
|
|
48
|
+
`FormField` wraps a draft **validated and committed together**. A create/edit **`Dialog` is
|
|
49
|
+
definitionally the second** — its Save/Create is the commit — so build it from `FormField` /
|
|
50
|
+
`FormTextInput` with controlled `value` + `onChangeText`, even when the record surface behind it
|
|
51
|
+
is a grid of inline editors.
|
|
52
|
+
|
|
53
|
+
This is **not** a safety rule, and it does not soften the press-gate law below ("Commit-on-blur
|
|
54
|
+
would race the press that caused it"): inline editors in a dialog lose nothing, because `Button`
|
|
55
|
+
already holds its press until the blur's commit settles. The gate makes the press SAFE. What it cannot do is make the dialog HONEST
|
|
56
|
+
while the field still has focus — it only acts at press time, and everything **derived** from the
|
|
57
|
+
draft trails the typing until then: a validity `error`, a Save gated on that validity, a computed
|
|
58
|
+
total. Type a valid value and the form still says invalid, and its Save still sits disabled, until
|
|
59
|
+
focus leaves. That is the dialog arguing with the box the reader is looking at, and no amount of
|
|
60
|
+
press-gating answers it, because the reader is not pressing anything yet.
|
|
61
|
+
|
|
62
|
+
Controlled inputs make derived state track what is on screen, and read shorter than the editors
|
|
63
|
+
they replace — four `DetailRow` + `Inline*` pairs whose `onSave` exists only to `setState` collapse
|
|
64
|
+
to four fields. Worked example: `tpl_record`'s New-customer dialog.
|
|
65
|
+
|
|
66
|
+
**But do not then derive the ERROR per keystroke** — the mistake controlled inputs invite, and the
|
|
67
|
+
opposite failure to the one above. Validity and the error MESSAGE are different questions. Validity
|
|
68
|
+
gates the commit and is checked continuously; the message claims the reader did something wrong, so
|
|
69
|
+
it may only be made once they are DONE. Every prefix of a correct fixed-length value is invalid, so
|
|
70
|
+
a per-keystroke message turns the first digit of a right answer into a red complaint. Gate the
|
|
71
|
+
message on a `touched` flag set `onBlur`, keep the commit gated on the real validity, and let a
|
|
72
|
+
showing error clear live as the value becomes valid — the shape [`useForm`](../src/use_form.ts)
|
|
73
|
+
already has: errors arrive from a validate pass, and editing a field clears that field's error.
|
|
74
|
+
|
|
44
75
|
## Inline edit — the preferred way to edit an existing record
|
|
45
76
|
|
|
46
77
|
When the whole record is editable (a detail/record screen, dense settings), don't wrap it in a
|
package/docs/templates.md
CHANGED
|
@@ -342,7 +342,9 @@ billing, and quick-capture templates. Top → bottom:
|
|
|
342
342
|
a floating `Callout`. Transport's three party rows are the same shape repeated, which is where
|
|
343
343
|
it earns itself: N references as field rows scan as one list, where N cards would be a wall.
|
|
344
344
|
Empty state = the find-or-create `Combobox` in the same value slot, custom row → the create
|
|
345
|
-
`Dialog` (tax ID + Fetch
|
|
345
|
+
`Dialog` (tax ID + Fetch). That dialog is built from **`FormField` / `FormTextInput`, never the
|
|
346
|
+
record surface's `Inline*` editors** — see [data_entry.md](./data_entry.md) §"A dialog is a
|
|
347
|
+
draft".
|
|
346
348
|
**Reference (the buyer's own) is the `actions` worked example**: its `Copy` is an
|
|
347
349
|
`InlineButton` ON the field, passed unconditionally and `disabled` while empty — not in the
|
|
348
350
|
row's `trailing`, and not conditional, which would resize the field as you type.
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -1036,7 +1036,9 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1036
1036
|
// — and the draft names its destination as DATA instead of the dialog holding a
|
|
1037
1037
|
// callback, so what is in flight stays inspectable and the commit stays one
|
|
1038
1038
|
// function.
|
|
1039
|
-
|
|
1039
|
+
// `taxIdTouched` is what decides whether the tax ID's format error may SHOW —
|
|
1040
|
+
// see `taxIdError` for why a controlled field must not simply derive it.
|
|
1041
|
+
const [custDraft, setCustDraft] = useState<{ target: PartyTarget; name: string; taxId: string; contact: string; city: string; taxIdTouched: boolean } | null>(null);
|
|
1040
1042
|
// The Fetch button's in-flight state (the registry lookup).
|
|
1041
1043
|
const [fetching, setFetching] = useState(false);
|
|
1042
1044
|
|
|
@@ -1369,11 +1371,22 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1369
1371
|
attachParty[target](opt.value);
|
|
1370
1372
|
return;
|
|
1371
1373
|
}
|
|
1372
|
-
setCustDraft({ target, name: opt.value, taxId: "", contact: "", city: "" });
|
|
1374
|
+
setCustDraft({ target, name: opt.value, taxId: "", contact: "", city: "", taxIdTouched: false });
|
|
1373
1375
|
};
|
|
1374
1376
|
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
+
// Two different questions, and conflating them is the trap a controlled field
|
|
1378
|
+
// walks into. VALIDITY gates the commit and is checked continuously. The
|
|
1379
|
+
// ERROR MESSAGE is a claim that the reader did something wrong, and it may
|
|
1380
|
+
// only be made once they are DONE — every prefix of a correct 10-digit id is
|
|
1381
|
+
// invalid, so deriving the message per keystroke turns "1" into a red
|
|
1382
|
+
// complaint and shouts at someone typing the right answer.
|
|
1383
|
+
//
|
|
1384
|
+
// Blur is "done", which is why `taxIdTouched` gates only the message. Once it
|
|
1385
|
+
// is showing, it clears the moment the value becomes valid — the same shape
|
|
1386
|
+
// `useForm` has: errors arrive from a validate pass, and editing a field
|
|
1387
|
+
// clears that field's error.
|
|
1388
|
+
const taxIdInvalid = !!custDraft && custDraft.taxId.trim() !== "" && !TAX_ID_RE.test(custDraft.taxId.trim());
|
|
1389
|
+
const taxIdError = taxIdInvalid && custDraft?.taxIdTouched ? "Tax ID is 10 or 13 digits" : undefined;
|
|
1377
1390
|
|
|
1378
1391
|
const createAndAttach = () => {
|
|
1379
1392
|
if (!custDraft) return;
|
|
@@ -3217,46 +3230,53 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
3217
3230
|
|
|
3218
3231
|
{/* find-or-create's CREATE branch — a focused Dialog (4 fields is
|
|
3219
3232
|
dialog-weight; an inline swap shifts the page). Essentials only: the
|
|
3220
|
-
dialog's Fetch fills contact + city off the tax ID.
|
|
3233
|
+
dialog's Fetch fills contact + city off the tax ID.
|
|
3234
|
+
|
|
3235
|
+
FORM controls, not the record surface's inline editors, and that is the
|
|
3236
|
+
whole point of the dialog: an inline editor SELF-PERSISTS on blur, a
|
|
3237
|
+
`FormField` wraps a draft validated and committed TOGETHER. This dialog
|
|
3238
|
+
is definitionally the second, so it is built from the second.
|
|
3239
|
+
|
|
3240
|
+
It was built from the first, and NOTHING WAS LOST BY THAT — the kit
|
|
3241
|
+
gates the press (`pending_commits`), so `Create & attach` waited for the
|
|
3242
|
+
blur's commit and a typed tax ID always survived. This is not a bug fix;
|
|
3243
|
+
the safety was never in question.
|
|
3244
|
+
|
|
3245
|
+
What the gate cannot do is make the dialog honest BEFORE the press. It
|
|
3246
|
+
acts at press time, so until then everything derived from the draft
|
|
3247
|
+
trails the typing: with a valid id on screen the field still read
|
|
3248
|
+
invalid and the button still sat disabled, until focus left. The reader
|
|
3249
|
+
is not pressing anything yet, so no press-gating answers it.
|
|
3250
|
+
|
|
3251
|
+
Controlled inputs make derived state track what is on screen — and cost
|
|
3252
|
+
less to read than the editors they replace. */}
|
|
3221
3253
|
<Dialog width={520} open={custDraft !== null} onOpenChange={(o) => { if (!o) setCustDraft(null); }}>
|
|
3222
3254
|
<DialogHeader>
|
|
3223
3255
|
<DialogHeaderTitle>New customer</DialogHeaderTitle>
|
|
3224
3256
|
</DialogHeader>
|
|
3225
|
-
{
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
{
|
|
3241
|
-
FormField's alert semantics), not a loose line below the table */}
|
|
3242
|
-
<DetailRow label="Tax ID" error={taxIdError}>
|
|
3243
|
-
{/* Fetch is NOT a verb about this value — it reads the tax ID and
|
|
3244
|
-
fills the contact and city BELOW it, so it belongs to the form,
|
|
3245
|
-
not to the field (which is why it isn't an `InlineButton` in
|
|
3246
|
-
`actions`). It composes into a row inside this value cell, so
|
|
3247
|
-
it costs the one row that wants it instead of a column every
|
|
3248
|
-
row would reserve. */}
|
|
3249
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}>
|
|
3257
|
+
<View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 16 }}>
|
|
3258
|
+
<FormTextInput
|
|
3259
|
+
label="Customer name"
|
|
3260
|
+
value={custDraft?.name ?? ""}
|
|
3261
|
+
onChangeText={(v) => setCustDraft((d) => (d ? { ...d, name: v } : d))}
|
|
3262
|
+
placeholder="Add legal name…"
|
|
3263
|
+
/>
|
|
3264
|
+
{/* Fetch is NOT a verb about this value — it reads the tax ID and
|
|
3265
|
+
fills the contact and city BELOW it, so it belongs to the form, not
|
|
3266
|
+
to the field. It composes into a row inside this field, so it costs
|
|
3267
|
+
the one field that wants it instead of a column every field would
|
|
3268
|
+
reserve. `FormField` + a bare input (rather than `FormTextInput`)
|
|
3269
|
+
is what lets the button share the line while the label, error and
|
|
3270
|
+
a11y wiring still belong to the input. */}
|
|
3271
|
+
<FormField label="Tax ID" error={taxIdError}>
|
|
3272
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
3250
3273
|
<View style={{ flex: 1, minWidth: 0 }}>
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
setCustDraft((d) => (d ? { ...d,
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
placeholder="Add tax ID…"
|
|
3258
|
-
accessibilityLabel="Tax ID"
|
|
3259
|
-
/>
|
|
3274
|
+
<TextInputField
|
|
3275
|
+
value={custDraft?.taxId ?? ""}
|
|
3276
|
+
onChangeText={(v) => setCustDraft((d) => (d ? { ...d, taxId: v } : d))}
|
|
3277
|
+
onBlur={() => setCustDraft((d) => (d ? { ...d, taxIdTouched: true } : d))}
|
|
3278
|
+
placeholder="Add tax ID…"
|
|
3279
|
+
/>
|
|
3260
3280
|
</View>
|
|
3261
3281
|
<Button
|
|
3262
3282
|
title="Fetch"
|
|
@@ -3265,35 +3285,28 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
3265
3285
|
disabled={!TAX_ID_RE.test((custDraft?.taxId ?? "").trim())}
|
|
3266
3286
|
onPress={fetchIntoDraft}
|
|
3267
3287
|
/>
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
<InlineTextInput
|
|
3283
|
-
value={custDraft?.city ?? ""}
|
|
3284
|
-
onSave={(v) => {
|
|
3285
|
-
setCustDraft((d) => (d ? { ...d, city: v } : d));
|
|
3286
|
-
return Promise.resolve();
|
|
3287
|
-
}}
|
|
3288
|
-
placeholder="Add city…"
|
|
3289
|
-
accessibilityLabel="City"
|
|
3290
|
-
/>
|
|
3291
|
-
</DetailRow>
|
|
3292
|
-
</DetailTable>
|
|
3288
|
+
</View>
|
|
3289
|
+
</FormField>
|
|
3290
|
+
<FormTextInput
|
|
3291
|
+
label="Contact"
|
|
3292
|
+
value={custDraft?.contact ?? ""}
|
|
3293
|
+
onChangeText={(v) => setCustDraft((d) => (d ? { ...d, contact: v } : d))}
|
|
3294
|
+
placeholder="Add contact…"
|
|
3295
|
+
/>
|
|
3296
|
+
<FormTextInput
|
|
3297
|
+
label="City"
|
|
3298
|
+
value={custDraft?.city ?? ""}
|
|
3299
|
+
onChangeText={(v) => setCustDraft((d) => (d ? { ...d, city: v } : d))}
|
|
3300
|
+
placeholder="Add city…"
|
|
3301
|
+
/>
|
|
3293
3302
|
</View>
|
|
3294
3303
|
<DialogFooter>
|
|
3295
3304
|
<Button title="Cancel" color="secondary" onPress={() => setCustDraft(null)} />
|
|
3296
|
-
|
|
3305
|
+
{/* Gated on the VALIDITY, never on the visible message: an invalid id
|
|
3306
|
+
must not commit just because the reader has not blurred out of the
|
|
3307
|
+
field yet. Pressing here blurs first, so the explanation appears in
|
|
3308
|
+
the same gesture that finds the button disabled. */}
|
|
3309
|
+
<Button title="Create & attach" color="primary" disabled={(custDraft?.name.trim() ?? "") === "" || taxIdInvalid} onPress={createAndAttach} />
|
|
3297
3310
|
</DialogFooter>
|
|
3298
3311
|
</Dialog>
|
|
3299
3312
|
|
package/package.json
CHANGED
package/src/detail_row.tsx
CHANGED
|
@@ -204,7 +204,7 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
204
204
|
{flatTuck !== 0 ? <View style={{ marginTop: flatTuck }}>{annotations}</View> : annotations}
|
|
205
205
|
</View>
|
|
206
206
|
) : (
|
|
207
|
-
<View style={annotated
|
|
207
|
+
<View style={[styles.spreadValue, annotated && styles.valueStack]}>
|
|
208
208
|
<View style={[styles.controlLine, { minHeight }]}>{children}</View>
|
|
209
209
|
{flatTuck !== 0 ? <View style={{ marginTop: flatTuck }}>{annotations}</View> : annotations}
|
|
210
210
|
</View>
|
|
@@ -239,7 +239,20 @@ const styles = StyleSheet.create({
|
|
|
239
239
|
alignItems: "flex-start",
|
|
240
240
|
gap: 12,
|
|
241
241
|
},
|
|
242
|
-
|
|
242
|
+
// Spread mode's label column. `flexGrow` is what pushes the value to the right
|
|
243
|
+
// edge; the BASIS is what stops the push from eating the label. It was
|
|
244
|
+
// `flex: 1` — grow 1, shrink 1, basis **0** — so the label entered the layout
|
|
245
|
+
// claiming no size at all, and a value too wide for the row (a company name in
|
|
246
|
+
// a stacked register) took the whole row while the label absorbed the entire
|
|
247
|
+
// deficit: measured at 0px wide and 140px tall, "Hợp đồng" set one character
|
|
248
|
+
// per line down the page.
|
|
249
|
+
//
|
|
250
|
+
// With `basis: auto` the label enters at its TEXT width, so a deficit is split
|
|
251
|
+
// between the two in proportion to what each actually asked for — a long value
|
|
252
|
+
// is the one that gives way, because it is the one that is long. Both stay
|
|
253
|
+
// shrinkable on purpose: a long LABEL against a short value has to wrap too,
|
|
254
|
+
// and pinning the label unshrinkable would just overflow the row the other way.
|
|
255
|
+
flexLabel: { flexGrow: 1, flexShrink: 1, flexBasis: "auto" },
|
|
243
256
|
// `center` is what makes a WRAPPED label align sanely, and it does both jobs
|
|
244
257
|
// with no measurement: a one-line label is shorter than the control band, so
|
|
245
258
|
// it centers on the control line (the alignment law); a wrapped label is as
|
|
@@ -255,6 +268,12 @@ const styles = StyleSheet.create({
|
|
|
255
268
|
// The 2px between the control line and the annotation block beneath it — the
|
|
256
269
|
// same rhythm `FieldAnnotations` puts between its own lines.
|
|
257
270
|
valueStack: { gap: 2 },
|
|
271
|
+
// Spread mode's value. A React Native `View` does NOT shrink by default
|
|
272
|
+
// (`flexShrink: 0`, unlike the web), so without this a wide value simply keeps
|
|
273
|
+
// its intrinsic width — overflowing the row and pushing the whole deficit onto
|
|
274
|
+
// the label. `minWidth: 0` is the half that lets a text value actually wrap
|
|
275
|
+
// rather than stopping at its longest word.
|
|
276
|
+
spreadValue: { flexShrink: 1, minWidth: 0 },
|
|
258
277
|
// Form mode (labelWidth set): the value column FILLS the row so every inline
|
|
259
278
|
// editor spans the SAME width — and none jumps wider when it swaps to the
|
|
260
279
|
// (flex:1) edit control. A stretch column, so an InlineEditView / InlineStatic
|