@lotics/ui 27.13.0 → 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.
@@ -494,7 +494,7 @@ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping
494
494
  (colored `Text`), not the buttons.
495
495
  - **Create buttons carry NO `+` icon** — "Add fee", not "+ Add fee". A create is `primary` and
496
496
  rides its section's HEADING row at the right edge — see § The add-placement law for why, and
497
- for the one-verb-one-weight rule that keeps it primary in the `EmptyState` too. (The
497
+ for the one-verb-one-weight rule that keeps it `primary` wherever it renders. (The
498
498
  icon-only `+` `IconButton` — a composer's attach — is a different thing and keeps its glyph.)
499
499
 
500
500
  ## The action-gating law — disabled + the reason as a co-located Callout
@@ -600,11 +600,13 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
600
600
 
601
601
  The ADD is the one verb this law does NOT place next to its effect, and the add-placement law
602
602
  says why: a position that never moves beats a position that is adjacent, because "below the
603
- last row" is a different place on every list and no place at all on an empty one.
604
- **An add is `primary`, in the `EmptyState` too.** It is the act its section offers, so it
605
- carries that section's weight; a lone `secondary` button reads as though the real action were
606
- somewhere else. The rule that matters is that ONE VERB KEEPS ONE WEIGHT the same add must
607
- not be primary on an empty list and secondary on a full one.
603
+ last row" is a different place on every list and no place at all on an empty one. The heading
604
+ row therefore carries identity + meta + view controls + the section's ADD, and no other content
605
+ verb. **An add is `primary` wherever it renders** on that heading row, or inside an
606
+ `EmptyState` in the one case § Empty states allows, where there is no heading to carry it. It
607
+ is the act its section offers, so it carries that section's weight; a lone `secondary` button
608
+ reads as though the real action were somewhere else. The rule that matters is that ONE VERB
609
+ KEEPS ONE WEIGHT — the same add must not be primary on an empty list and secondary on a full one.
608
610
  A record surface therefore carries several primaries, one per section, and that is correct:
609
611
  "one primary per SCREEN" governs the page BAND, not a stack of independent sections each with
610
612
  its own single act. The cost is real and worth knowing — the more sections offer an act, the
@@ -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
@@ -53,6 +53,22 @@ the package index is [../AGENTS.md](../AGENTS.md).
53
53
  | AI ranks answers — look-up-and-explain | `tpl_lookup` |
54
54
  | AI over a record's documents (extract / cross-check / generate) | `tpl_record` — its Documents + Document set sections |
55
55
 
56
+ ### Register + drawer, or a navigated page?
57
+
58
+ The register is the DEFAULT, and its row press opens the workspace `Drawer`. A record earns
59
+ its own navigated route only when one of these holds:
60
+
61
+ - **It is a destination.** Someone arrives cold from outside the register — a link in an
62
+ email, a notification, a handoff from another desk — and must land on the record without
63
+ the list to open it from.
64
+ - **The work needs the canvas.** A docked comments gutter, a document read beside the fields,
65
+ an outline rail worth navigating — width the drawer's single column cannot give.
66
+
67
+ The volume of DATA is not the test. A record with forty fields still closes in the drawer if
68
+ the operator opens it from the list, works it, and goes back to the list. What an app without
69
+ a destination skips is the ROUTE, never the template: the drawer renders `tpl_record` either
70
+ way, so "no record page" must never become a hand-built detail surface.
71
+
56
72
  ## Composition rules — how a record surface is assembled
57
73
 
58
74
  Laws for any screen where a record's data is edited and a primary action produces its output
@@ -268,7 +284,10 @@ billing, and quick-capture templates. Top → bottom:
268
284
  ATTENTION state as a co-located `Callout` (e.g. overdue — record-scoped state reads here;
269
285
  field-scoped state stays on its field), then a quiet `SummaryLine` (desk, documents
270
286
  , to collect).
271
- - **GENERAL — the MAIN details, FIRST in the rail**; every later section is supplementary.
287
+ - **GENERAL — the MAIN details, first in THIS record's rail** because its work starts at the
288
+ details; rule 1 orders sections by work flow, so a surface whose work starts at a DOCUMENT
289
+ (an invoice desk, a claims desk) leads with Documents and General follows. Every later
290
+ section is supplementary.
272
291
  A `SubsectionStack` (space-only beat) of a headingless LEAD group (the key facts) plus
273
292
  named groups (e.g. Order / Fulfilment / System — system identifiers get a HOME, not a
274
293
  per-row badge). Every group's `DetailTable` repeats the same `labelWidth` so the grid
@@ -323,7 +342,9 @@ billing, and quick-capture templates. Top → bottom:
323
342
  a floating `Callout`. Transport's three party rows are the same shape repeated, which is where
324
343
  it earns itself: N references as field rows scan as one list, where N cards would be a wall.
325
344
  Empty state = the find-or-create `Combobox` in the same value slot, custom row → the create
326
- `Dialog` (tax ID + Fetch; its field errors ride `DetailRow error`, not a loose line).
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".
327
348
  **Reference (the buyer's own) is the `actions` worked example**: its `Copy` is an
328
349
  `InlineButton` ON the field, passed unconditionally and `disabled` while empty — not in the
329
350
  row's `trailing`, and not conditional, which would resize the field as you type.
@@ -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
- const [custDraft, setCustDraft] = useState<{ target: PartyTarget; name: string; taxId: string; contact: string; city: string } | null>(null);
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
- const taxIdError =
1376
- custDraft && custDraft.taxId && !TAX_ID_RE.test(custDraft.taxId.trim()) ? "Tax ID is 10 or 13 digits" : undefined;
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
- {/* the same inline-chip table vocabulary as the record's field grids —
3226
- only the commit differs (Create & attach) */}
3227
- <View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 8 }}>
3228
- <DetailTable labelWidth={150}>
3229
- <DetailRow label="Customer name">
3230
- <InlineTextInput
3231
- value={custDraft?.name ?? ""}
3232
- onSave={(v) => {
3233
- setCustDraft((d) => (d ? { ...d, name: v } : d));
3234
- return Promise.resolve();
3235
- }}
3236
- placeholder="Add legal name…"
3237
- accessibilityLabel="Customer name"
3238
- />
3239
- </DetailRow>
3240
- {/* the field-level failure rides the ROW (`DetailRow error` —
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
- <InlineTextInput
3252
- value={custDraft?.taxId ?? ""}
3253
- onSave={(v) => {
3254
- setCustDraft((d) => (d ? { ...d, taxId: v } : d));
3255
- return Promise.resolve();
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
- </View>
3269
- </DetailRow>
3270
- <DetailRow label="Contact">
3271
- <InlineTextInput
3272
- value={custDraft?.contact ?? ""}
3273
- onSave={(v) => {
3274
- setCustDraft((d) => (d ? { ...d, contact: v } : d));
3275
- return Promise.resolve();
3276
- }}
3277
- placeholder="Add contact…"
3278
- accessibilityLabel="Contact"
3279
- />
3280
- </DetailRow>
3281
- <DetailRow label="City">
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
- <Button title="Create & attach" color="primary" disabled={(custDraft?.name.trim() ?? "") === "" || !!taxIdError} onPress={createAndAttach} />
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "27.13.0",
3
+ "version": "27.13.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -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 ? styles.valueStack : undefined}>
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
- flexLabel: { flex: 1 },
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