@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 +19 -0
- package/docs/catalog.md +4 -3
- 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/src/table.tsx +20 -5
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:
|
|
977
|
-
|
|
978
|
-
|
|
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 value — and 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).
|
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
|
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
|
-
|
|
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
|
|
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
|
}
|