@lotics/ui 27.13.1 → 27.13.3

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/MIGRATION.md CHANGED
@@ -4,6 +4,25 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 27.13.3 — a stacked `Table` row's identity cell no longer renders its column label
8
+
9
+ No API change, but the rendered output changed on NARROW containers only (below the
10
+ two-column floor, where `Table` stacks). The identity cell — column 0 — used to render as a
11
+ `DetailRow` spread like every other cell, so its column label appeared beside the value. It
12
+ now heads the row bare. **A test or script that finds that label inside a stacked row must be
13
+ updated**:
14
+
15
+ ```ts
16
+ // a 375px-wide register whose first column is labelled "Customer"
17
+ getByText("Customer") // BEFORE — the identity cell's label, once per ROW
18
+ // AFTER — matches the column HEADER only (wide containers)
19
+ ```
20
+
21
+ Field cells (index ≥ 1) keep their labels, and register mode is untouched — this is stacked
22
+ mode only. The label restated the value it sat beside (`Customer` over `Northwind Packaging`),
23
+ and it had nowhere to go: a label and a full-width identity on one line with nothing to give
24
+ made the label collapse under a long value, which is what a narrow register used to render.
25
+
7
26
  ## 27.4.0 — a `Select` trigger announces as `combobox`, not `button`
8
27
 
9
28
  No API change, but the DOM and the accessibility tree changed, so **a test or script that
package/docs/catalog.md CHANGED
@@ -973,9 +973,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
973
973
  CONTAINER-RESPONSIVE with no prop (measures itself, like `Breakdown`/`DetailTable`): when
974
974
  the width can't fit every column it hides columns by `TableColumn.priority` (higher drops
975
975
  first; default = column order, rightmost first; column 0 — the identity — never drops), and
976
- below the two-column floor every row STACKS: each cell renders as a `DetailRow`-spread
977
- line (muted label left, value at the right edge one vocabulary with the drawer's detail
978
- rows), the lines sitting on the identity column's text edge, never under the checkbox;
976
+ below the two-column floor every row STACKS: the identity cell (column 0) HEADS the pile
977
+ unlabelled its column name would only restate the valueand each REMAINING cell renders
978
+ as a `DetailRow`-spread line (muted label left, value at the right edge one vocabulary with
979
+ the drawer's detail rows), the lines sitting on the identity column's text edge, never under the checkbox;
979
980
  the header band gives way, so sorting is a wide-container affordance. Rows carry TWO right-side slots: `action`
980
981
  (the primary CTA `Button` — register: in the trailing gutter; stacked: closes the content,
981
982
  right-aligned) and `trailing` (the ⋯ overflow — stays beside the identity on the top line).
@@ -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; 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".
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.
@@ -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.1",
3
+ "version": "27.13.3",
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
package/src/table.tsx CHANGED
@@ -229,7 +229,10 @@ export function TableRow(props: TableRowProps) {
229
229
  // A dropped column drops its cell (stacked shows everything). Cells beyond
230
230
  // the declared columns stay — same tolerance as the width mapping.
231
231
  .filter(({ column }) => ctx.stacked || column == null || ctx.visibleKeys.has(column.key))
232
- .map(({ cell, column }) => cloneElement(cell, { _column: column, _stacked: ctx.stacked }));
232
+ // Index 0 is the identity cell it HEADS the stacked pile rather than becoming
233
+ // one of its labelled field lines. (Safe to read post-filter: stacked mode drops
234
+ // no cell, and column 0 never drops in register mode either.)
235
+ .map(({ cell, column }, i) => cloneElement(cell, { _column: column, _stacked: ctx.stacked, _primary: i === 0 }));
233
236
 
234
237
  const body = ctx.stacked ? (
235
238
  <View style={styles.stackedBody}>
@@ -314,19 +317,31 @@ export interface TableCellProps {
314
317
  _column?: TableColumn;
315
318
  /** @internal — injected by `TableRow`; the Table is in stacked mode. */
316
319
  _stacked?: boolean;
320
+ /** @internal — injected by `TableRow`; this is the identity cell (column 0), which
321
+ * in stacked mode leads the row instead of becoming one of its field lines. */
322
+ _primary?: boolean;
317
323
  }
318
324
 
319
325
  /** One cell — its width/align come from the column `TableRow` injects by position.
320
- * In stacked mode it IS a `DetailRow` (spread: muted label left, value at the
321
- * right edge) — the drawer's detail-row component, not a lookalike, so a
326
+ * In stacked mode a FIELD cell is a `DetailRow` (spread: muted label left, value
327
+ * at the right edge) — the drawer's detail-row component, not a lookalike, so a
322
328
  * stacked register and the record workspace behind its door share one
323
329
  * vocabulary by construction. `DetailRow` values are arbitrary nodes (the
324
330
  * drawer renders badges, money stacks, editors, popover triggers in them), so
325
331
  * register cell content needs no adaptation. A label-less control column has
326
- * no line to spread — its content pins to the right edge. */
332
+ * no line to spread — its content pins to the right edge.
333
+ *
334
+ * The IDENTITY cell is not a field line and carries no label. It heads the row —
335
+ * the thing you read to know WHICH record this is — so its column name ("Hợp đồng",
336
+ * "Customer") only restates what the value already says, and it already sits on the
337
+ * top line beside the selection checkbox and the ⋯ overflow, where a label has no
338
+ * field grid to align to. */
327
339
  export function TableCell(props: TableCellProps) {
328
- const { children, _column, _stacked } = props;
340
+ const { children, _column, _stacked, _primary } = props;
329
341
  if (_stacked) {
342
+ if (_primary) {
343
+ return <>{children}</>;
344
+ }
330
345
  if (!_column?.label) {
331
346
  return <View style={styles.stackedBareCell}>{children}</View>;
332
347
  }