@lotics/ui 24.0.2 → 25.0.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/AGENTS.md CHANGED
@@ -67,9 +67,16 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
67
67
  `expanded` reveal the detail beneath the row; keep the drawer for a heavy form.
68
68
  → [catalog.md](./docs/catalog.md).
69
69
  - **A reference to another record is a FIELD** — it wears the inline editor's own resting
70
- surface, carries an **`InlineButton`** Open inside it, and PEEKS its facts on press (detach
71
- lives in the peek). Never its own Section, never a card, never the other record's fields as
72
- rows on this one. [composition.md](./docs/composition.md).
70
+ surface and PEEKS its facts on press; EVERY verb lives in the peek's footer as a `Button`,
71
+ none on the field LEFT the link (Change and Clear, both standard), RIGHT the record (Edit's
72
+ draft, and the optional Open). Change is `secondary`, never `danger`: it is a correction. After
73
+ Change hand the picker focus, after Clear never. Never its own
74
+ Section, never a card, never the other record's fields as rows on this one. **A peek can
75
+ hold editors only as a DRAFT** — `Edit` → inputs → `Save` (which sends only what CHANGED),
76
+ with the open draft pinning the popover so a stray click cannot discard typing; a
77
+ self-persisting inline editor may never go in one, because there blur and dismiss are the
78
+ same gesture.
79
+ → [composition.md](./docs/composition.md), [catalog.md](./docs/catalog.md).
73
80
  - **A verb about a VALUE goes on the value** — an `InlineButton` in the field's `actions`
74
81
  (Copy, Open), never the row's `trailing`: a control at the page's right edge has to be paired
75
82
  back to the field it belongs to, and one row's action makes EVERY row reserve the column.
package/MIGRATION.md CHANGED
@@ -4,6 +4,44 @@ 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
+ ## 25.0.0 — `ReferenceField` edits in place; `onRemove` becomes `onClear`
8
+
9
+ **`onRemove` is now `onClear` — a pure rename, identical behaviour.** The act was always
10
+ "detach and leave it empty"; only the VERB was wrong. Named "Remove" and coloured `danger`, a
11
+ recoverable unset wore the ink reserved for destruction. Rename the prop and nothing else
12
+ changes — same write, same follow-through, nothing focused.
13
+
14
+ ```tsx
15
+ <ReferenceField … onRemove={() => setNotify(null)} /> // BEFORE — "Remove", danger
16
+ <ReferenceField … onClear={() => setNotify(null)} /> // AFTER — "Clear", secondary
17
+ ```
18
+
19
+ **`onChange` is NEW, and also required.** Same write, but it means "wrong record, I'm about to
20
+ pick another", so the caller hands the picker focus afterwards. Every `ReferenceField` now takes
21
+ BOTH — including one the record cannot do without, since withholding Clear there enforces nothing
22
+ (Change, then decline to pick, lands on the same empty state) and only makes the footer's shape
23
+ vary per call site.
24
+
25
+ **Hand the picker focus after Change, and only after Change.** Both verbs unset the reference and
26
+ land on your empty state, but they mean opposite things: Change said "wrong one" (open the picker
27
+ focused — `Combobox`'s `autoFocus` both focuses and opens), Clear said "there is none" (focus
28
+ nothing, or you argue with the press). Track it as state — "empty" and "empty because Change was
29
+ just pressed" look identical otherwise, and a field that starts empty must not steal the caret.
30
+
31
+ **`onOpen`/`openLabel` are now OPTIONAL.** A reference whose facts can be corrected in place has
32
+ no errand to send the reader on. Existing call sites keep working; drop them where Open only
33
+ re-showed what the peek already said.
34
+
35
+ **New: `onSave`, and `facts` gained `name`/`multiline`.** A fact carrying a `name`
36
+ is editable and saves under that key; `Edit` opens a DRAFT in the same grid and `Save` fires
37
+ `onSave` with only what CHANGED. The peek pins itself open while a draft is live, so a stray
38
+ click cannot bin the typing. `facts` stays backward-compatible — `{label, value}` alone is
39
+ read-only, exactly as before.
40
+
41
+ **All four verbs are `Button`s now** (`TextButton` is gone from this component): Change and Clear
42
+ on the left act on the LINK, Edit and Open on the right act on the RECORD, Edit taking the one
43
+ filled-dark rung and handing it to Save when the draft opens.
44
+
7
45
  ## 24.0.0 — `TextLink` navigates, `TextButton` acts
8
46
 
9
47
  ```tsx
package/docs/catalog.md CHANGED
@@ -748,7 +748,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
748
748
  EXACTLY (height, padding, 1px transparent border) so a non-editable field — a computed
749
749
  total, a system ID, a synced/locked value — aligns pixel-for-pixel in the same column;
750
750
  non-interactive, NOT a disabled input; `muted`/`tabular`/`align="right"` for a number
751
- column, `weight="medium"` to emphasise a total among plain rows.
751
+ column, `weight="medium"` to emphasise a total among plain rows, `multiline` to WRAP rather
752
+ than truncate (single-line is the default, because a field column reads as a column only if
753
+ its rows are one height — pass it where the counterpart editor is itself `multiline`, or a
754
+ truncated value beside a wrapping input reintroduces the misalignment on the other axis).
755
+ **Reach for it over a bare `Text` in any grid that has editors in some state**: an input's
756
+ words start at `CONTROL_TEXT_INSET` (1px border + 8px padding) and a raw `Text` starts at 0,
757
+ so a read/edit toggle slides every value 9px sideways.
752
758
  - **`sequence`** — `Sequence` + `SequenceItem` (+ `SEQUENCE_INSET`): an ORDERED list whose ORDER
753
759
  IS THE DATA — a route's stops, an approval chain, a set of legs — drawn as a connected rail so
754
760
  the sequence reads without a label saying "first"/"then". Reach for it when a thing has a
@@ -823,11 +829,45 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
823
829
  - **`reference_field`** — `ReferenceField`: a reference to ANOTHER RECORD, rendered as a
824
830
  FIELD VALUE — the kit's inline-editor surface (so a pointer sits in the value column
825
831
  like the editors above and below it), whose press opens a PEEK of that record's facts.
826
- `name` + optional `code` + `facts` (label/value pairs) + `onOpen`/`openLabel` and an
827
- optional `onRemove`. The verbs live INSIDE the peek, never on the field: `Open` goes to
828
- the referenced record's own page, `Remove` detaches both come from the locale pack
829
- (`referenceField.open` / `.remove`), while `openLabel` stays a prop because it names the
830
- DESTINATION for a screen reader, which only the caller knows. The value is a `TextLink` marker
832
+ `name` + optional `code` + `facts`, plus the verb callbacks below. The verbs live INSIDE the
833
+ peek, never on the field, and they are ALL `Button`s at one altitude — a peek is
834
+ dialog-scale and a dialog's verbs are buttons, so mixing weights only made the reader rank
835
+ four acts that are four acts. The row splits by WHAT EACH TOUCHES, and the spacer is that
836
+ seam: **left, the LINK** `Change` (`onChange`, `secondary`) points the field at a different
837
+ record, named for the intent and deliberately not `danger` since a recoverable correction must
838
+ not wear the colour reserved for destruction; `Clear` (`onClear`, NO fill — the least-reached
839
+ verb of the four) leaves it empty. **Both are REQUIRED**, so the footer has one shape
840
+ everywhere; a reference the record cannot do without gains nothing from hiding Clear, because
841
+ Change and then declining to pick reaches the same empty state. **Right, the RECORD it points at** — `Edit` (`primary`, the one filled-dark rung,
842
+ handed to `Save` when the draft opens) and `Open` (`secondary`, rightmost, OPTIONAL, the
843
+ departure — not prioritized, because on a peek whose facts are correctable in place leaving is
844
+ the rarest move). Every verb is optional and the footer renders only when it HAS one, so an
845
+ edit-only reference draws no rule under nothing.
846
+ **Both unset — they differ only in FOLLOW-THROUGH.** `Clear` is the plain detach (nothing
847
+ focused); `Change` is the same write plus the caller's focus handoff. They are separate verbs
848
+ because the follow-through is not inferable from the write: nothing downstream can tell "unset
849
+ it" from "unset it, I'm about to pick another". Whether empty is VALID is the row's business — its
850
+ `warning`, its validation — never the peek's.
851
+ **Hand the picker focus after `Change`, never after `Clear`.** Both land on your empty state.
852
+ Change said "wrong one", so open the picker focused (`Combobox`'s `autoFocus` both focuses AND
853
+ opens) and the correction stays one gesture instead of three; Clear said "there is none", so
854
+ focusing would argue with the press and leave a menu to dismiss. Track it as state — "empty"
855
+ and "empty because Change was just pressed" are indistinguishable otherwise, and a field that
856
+ starts empty must not steal the caret on load.
857
+ Labels come from the locale pack (`referenceField.open` / `.change` / `.clear` / `.edit` /
858
+ `.save` / `.saving` / `.cancel`), while `openLabel` stays a prop because it names the
859
+ DESTINATION for a screen reader, which only the caller knows. **EDIT IN THE PEEK** (`onSave`):
860
+ a fact carrying a `name` is editable and that key is what it saves under (omit `name` for a
861
+ derived value that rides along read-only); `multiline` for an address or an account block.
862
+ `Edit` swaps the SAME grid's value cells for inputs — a DRAFT, so nothing commits until `Save`,
863
+ which fires `onSave` with **only the facts that CHANGED** (never a snapshot, so a lock or
864
+ `before_update` sees the real edit). This is what lets a peek hold editors at all: a
865
+ self-persisting inline editor cannot, because there blur and dismiss are ONE gesture — an open
866
+ draft instead PINS the popover (outside-press and Escape stop dismissing), making
867
+ `Cancel`/`Save` the only exits, and the draft's footer REPLACES the peek's rather than joining
868
+ it (Change, Clear and Open are all moves away from unsaved typing). A failed save KEEPS the
869
+ draft. `Save` is disabled until something differs, since an empty write still fires the
870
+ record's hooks and bumps its modified stamp. The value is a `TextLink` marker
831
871
  (underlined, neutral) because the press opens a summary rather than navigating. A
832
872
  reference is a FIELD, not a section — one pointer to one other row never earns a heading
833
873
  and a rail entry. Worked example: [`tpl_record`](../examples/tpl_record.tsx) § General
@@ -615,18 +615,30 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
615
615
  a pointer at another record sits in the value column exactly like the editors above and below
616
616
  it. Put BOTH the name and its code INSIDE it — one entity reads as one object, and a muted
617
617
  code beside a link reads as an orphan.
618
- **THREE acts, placed by how often they happen and what they cost to undo**: pressing the
619
- surface PEEKS the facts (free, so it gets the biggest target); an **`InlineButton`** Open sits
620
- INSIDE on the right (the common act, so it stays in sight and stays labelleda press target
621
- you discover by hovering is not an affordance); and DETACH lives in the peek's footer
622
- (destructive and rare, and it is the answer to the question the peek asks "is this the right
623
- one?" so the check and the correction are one gesture). A detach link repeated down a column
624
- of references is noise in the scan path for an act most readers never perform.
625
- The interior verb REQUIRES the anatomy a container-with-controls always requires: a role-less,
626
- non-focusable surface (a button must never contain a button), an empty `PressDoor` sibling
627
- carrying the tab stop / name / focus ring, and the verb lifted above it on `zIndex`. Track
628
- hover on the container itself, or react-native-web releases it to the innermost pressable and
629
- the border flickers out as the pointer crosses the verb.
618
+ **ONE act on the surface, the rest one layer in.** Pressing the field PEEKS the facts free,
619
+ so it takes the whole box as its target and every verb lives INSIDE the peek, placed by
620
+ four `Button`s in the footer, split by what they touch: LEFT the linkChange (point it
621
+ elsewhere) and Clear (leave it empty), both always present RIGHT the record it points at — Edit (the
622
+ draft) and the optional Open (depart to its page). A peek is dialog-scale and a dialog's verbs
623
+ are buttons, so they share one altitude; the spacer between the pairs IS the seam, not padding.
624
+ Change is `secondary` and NOT `danger` a correction, not a destruction Edit takes the one
625
+ filled-dark rung and hands it to Save, Open stays `secondary` and never prioritized. None of
626
+ them sits on the field. After Change, hand the picker focus; after Clear, never the two
627
+ verbs land in the same place meaning opposite things. An interior verb was tried and removed: it put two destinations behind one object
628
+ (press the box a summary; press 8px right → navigate), it demanded the whole
629
+ container-with-controls anatomy (a role-less surface, a `PressDoor` for the tab stop, the verb
630
+ lifted on `zIndex`, hover tracked on the container), and it was neither common nor an act
631
+ ABOUT the value — leaving is the rare move on a record page. Without it the field has its plain
632
+ anatomy back: the press target is the box, the focus ring rings the field.
633
+ Detach earns its depth twice — destructive and rare, and it ANSWERS the question the peek asks
634
+ ("is this the right one?"), so the check and the correction are one gesture. A detach link
635
+ repeated down a column of references is noise for an act most readers never perform.
636
+ **Open is OPTIONAL, and Edit is why.** A reference whose facts can be corrected in the peek has
637
+ no errand to send the reader on, and a verb wired to nothing is worse than an absent one. Edit
638
+ opens a DRAFT in the same grid — nothing commits until Save, which sends only what CHANGED —
639
+ and the open draft PINS the popover so outside-press and Escape cannot bin the typing. That
640
+ pinning is the whole licence: a peek may hold a draft, and may never hold a self-persisting
641
+ inline editor, because there blur and dismiss are one gesture.
630
642
  **Never promote it to a Section.** A section claims a distinct AREA of the record and buys an
631
643
  outline-rail entry; one pointer at one other row never earns that. What tempts the promotion is
632
644
  the machinery around it — and each piece is just part of the field: the search is its EMPTY
@@ -124,16 +124,19 @@ interface Customer {
124
124
  taxId: string;
125
125
  contact: string;
126
126
  city: string;
127
+ /** Runs past one line at a peek's width — the case a fixture of short values
128
+ * never exercises, and the one where a read value and its editor diverge. */
129
+ address: string;
127
130
  }
128
131
 
129
132
  // Atlas ships WITHOUT a tax ID — attach it to see the billing gate + the
130
133
  // inline Tax ID fix-up in the Customer section.
131
134
  const KNOWN_CUSTOMERS: Customer[] = [
132
- { id: "cus_01", name: "Northwind Traders", code: "KH-0148", taxId: "0312456780", contact: "Mara Lindqvist", city: "Gothenburg" },
133
- { id: "cus_02", name: "Harbor Freight Lines", code: "KH-0203", taxId: "0312998820", contact: "Diego Alvarez", city: "Rotterdam" },
134
- { id: "cus_03", name: "Summit Packaging Co.", code: "KH-0231", taxId: "0301557742", contact: "Priya Nair", city: "Singapore" },
135
- { id: "cus_04", name: "Atlas Distribution", code: "KH-0117", taxId: "", contact: "Tom Becker", city: "Hamburg" },
136
- { id: "cus_05", name: "Bluewater Logistics", code: "KH-0294", taxId: "0312004455", contact: "Lena Fischer", city: "Antwerp" },
135
+ { id: "cus_01", name: "Northwind Traders", code: "KH-0148", taxId: "0312456780", contact: "Mara Lindqvist", city: "Gothenburg", address: "Ringvägen 118, 4 tr, 116 61 Stockholm, Sweden" },
136
+ { id: "cus_02", name: "Harbor Freight Lines", code: "KH-0203", taxId: "0312998820", contact: "Diego Alvarez", city: "Rotterdam", address: "Waalhaven Oostzijde 81, 3087 BM Rotterdam, Netherlands" },
137
+ { id: "cus_03", name: "Summit Packaging Co.", code: "KH-0231", taxId: "0301557742", contact: "Priya Nair", city: "Singapore", address: "9 Tuas Bay Walk, #03-14, Singapore 637803" },
138
+ { id: "cus_04", name: "Atlas Distribution", code: "KH-0117", taxId: "", contact: "Tom Becker", city: "Hamburg", address: "Grosser Grasbrook 9, 20457 Hamburg, Germany" },
139
+ { id: "cus_05", name: "Bluewater Logistics", code: "KH-0294", taxId: "0312004455", contact: "Lena Fischer", city: "Antwerp", address: "Noorderlaan 127, 2030 Antwerpen, Belgium" },
137
140
  ];
138
141
 
139
142
  const TAX_ID_RE = /^\d{10}(\d{3})?$/;
@@ -704,15 +707,24 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
704
707
  /** A party ROW — a reference when one is attached, the find-or-create picker when
705
708
  * not. The two states share the row so the field never moves; the picker is the
706
709
  * field's EMPTY state, not a different kind of surface. */
707
- function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onRemove }: {
710
+ function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onUnset, onSaveFacts }: {
708
711
  role: string;
709
712
  rec: Customer | null;
710
713
  options: PickerOption<string, Customer>[];
711
714
  placeholder: string;
712
715
  onPick: (opt: PickerOption<string, Customer>) => void;
713
716
  onOpen: () => void;
714
- onRemove: () => void;
717
+ onUnset: () => void;
718
+ onSaveFacts: (id: string, patch: Record<string, string>) => void;
715
719
  }) {
720
+ // THE FOCUS HANDOFF, and only for Change. Both verbs unset the reference, so
721
+ // both land on the picker below — but they arrive meaning opposite things.
722
+ // Change said "wrong one", and the next move is choosing the right one, so the
723
+ // picker opens focused and the correction stays ONE gesture instead of three
724
+ // (press, hunt for the input, click it). Clear said "there is none": opening a
725
+ // picker there would argue with the press, and leave a menu the reader has to
726
+ // dismiss to get the empty state they just asked for.
727
+ const [changing, setChanging] = useState(false);
716
728
  return (
717
729
  <DetailRow label={role}>
718
730
  {rec ? (
@@ -720,17 +732,25 @@ function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onRemove }:
720
732
  name={rec.name}
721
733
  code={rec.code}
722
734
  facts={[
723
- { label: "Contact", value: rec.contact },
724
- { label: "City", value: rec.city },
725
- { label: "Tax ID", value: rec.taxId },
735
+ { label: "Contact", value: rec.contact, name: "contact" },
736
+ { label: "City", value: rec.city, name: "city" },
737
+ { label: "Tax ID", value: rec.taxId, name: "taxId" },
738
+ { label: "Address", value: rec.address, name: "address", multiline: true },
726
739
  ]}
727
740
  accessibilityLabel={`${rec.name} — details`}
728
741
  openLabel={`Open ${rec.name}`}
729
742
  onOpen={onOpen}
730
- onRemove={onRemove}
743
+ onChange={() => { setChanging(true); onUnset(); }}
744
+ onClear={() => { setChanging(false); onUnset(); }}
745
+ onSave={(patch) => onSaveFacts(rec.id, patch)}
731
746
  />
732
747
  ) : (
733
- <Combobox options={options} onValueChange={onPick} reflectSelection={false}>
748
+ <Combobox
749
+ options={options}
750
+ autoFocus={changing}
751
+ onValueChange={(o) => { setChanging(false); onPick(o); }}
752
+ reflectSelection={false}
753
+ >
734
754
  <ComboboxInput icon="search" placeholder={placeholder} accessibilityLabel={role} />
735
755
  <ComboboxContent emptyText="No match" />
736
756
  </Combobox>
@@ -818,6 +838,12 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
818
838
  // the Customer section (that's what un-gates invoicing).
819
839
  const [customers, setCustomers] = useState<Customer[]>(KNOWN_CUSTOMERS);
820
840
  const [customerId, setCustomerId] = useState<string | null>("cus_02");
841
+ // Set by Change, consumed by the picker's `autoFocus`, cleared on pick. It has
842
+ // to be state rather than a derived value: "empty" and "empty BECAUSE the
843
+ // reader just pressed Change" look identical on the record, and only the
844
+ // second should steal focus — a field that starts empty must not grab the
845
+ // caret on load.
846
+ const [changingCustomer, setChangingCustomer] = useState(false);
821
847
  const customer = customers.find((c) => c.id === customerId) ?? null;
822
848
 
823
849
  // ── facts + details (all inline-persisted)
@@ -835,7 +861,11 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
835
861
  // record arrives at, and the surface every compaction idea has to be judged
836
862
  // against — measure any change here, not on a four-field group.
837
863
  const [shipTo, setShipTo] = useState<string | null>("cus_03");
838
- const [notify, setNotify] = useState<string | null>(null);
864
+ // Seeded so the ONE row carrying all four verbs is reachable at rest. Its own
865
+ // Clear returns it to the empty picker in a press, so the empty state this
866
+ // used to demonstrate at rest is still one gesture away — and now the full
867
+ // footer is too, which it never was.
868
+ const [notify, setNotify] = useState<string | null>("cus_01");
839
869
  const [carrier, setCarrier] = useState<string | null>("cus_05");
840
870
  const [docType, setDocType] = useState("original");
841
871
  const [waybill, setWaybill] = useState("WB-4471902");
@@ -856,6 +886,23 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
856
886
  ]);
857
887
  const [dropPoint, setDropPoint] = useState("");
858
888
  const [scheduleNote, setScheduleNote] = useState("delayed");
889
+ // One writer for every party's facts — the peek's draft hands back only the
890
+ // keys that CHANGED, so each is applied over the record it names and the rest
891
+ // of that customer is left alone.
892
+ const saveCustomerFacts = (id: string, patch: Record<string, string>) =>
893
+ setCustomers((prev) =>
894
+ prev.map((c) =>
895
+ c.id === id
896
+ ? {
897
+ ...c,
898
+ contact: patch.contact ?? c.contact,
899
+ city: patch.city ?? c.city,
900
+ taxId: patch.taxId ?? c.taxId,
901
+ address: patch.address ?? c.address,
902
+ }
903
+ : c,
904
+ ),
905
+ );
859
906
  const shipToRec = customers.find((c) => c.id === shipTo) ?? null;
860
907
  const notifyRec = customers.find((c) => c.id === notify) ?? null;
861
908
  const carrierRec = customers.find((c) => c.id === carrier) ?? null;
@@ -1191,6 +1238,9 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1191
1238
  taxId: custDraft.taxId.trim(),
1192
1239
  contact: custDraft.contact.trim(),
1193
1240
  city: custDraft.city.trim(),
1241
+ // Not collected at create — a new party is attached from three fields and
1242
+ // the rest is filled in later, which is what the peek's Edit is for.
1243
+ address: "",
1194
1244
  };
1195
1245
  setCustomers((prev) => [...prev, c]);
1196
1246
  setCustomerId(c.id);
@@ -1655,26 +1705,66 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1655
1705
  <ReferenceField
1656
1706
  name={customer.name}
1657
1707
  code={customer.code}
1708
+ /* A fact carrying a `name` is EDITABLE — the key it saves
1709
+ under. The three here are the customer's own columns; a
1710
+ derived one (a balance, a rating) would omit `name` and
1711
+ ride along read-only. */
1658
1712
  facts={[
1659
- { label: "Tax ID", value: customer.taxId },
1660
- { label: "Contact", value: customer.contact },
1661
- { label: "City", value: customer.city },
1713
+ { label: "Tax ID", value: customer.taxId, name: "taxId" },
1714
+ { label: "Contact", value: customer.contact, name: "contact" },
1715
+ { label: "City", value: customer.city, name: "city" },
1716
+ { label: "Address", value: customer.address, name: "address", multiline: true },
1662
1717
  ]}
1663
1718
  accessibilityLabel={`${customer.name} — details`}
1664
- openLabel="Open customer"
1719
+ /* NO `onOpen`. The peek carries every fact this record
1720
+ holds about the customer AND can correct them, so
1721
+ "Open" resolved to "read that again, wider" — the exact
1722
+ thing the deleted customer drawer was doing. The party
1723
+ rows below still pass it: a carrier's page has related
1724
+ lists a peek can never hold, and that is a real errand.
1725
+ The prop is optional so each call site answers for
1726
+ itself instead of wiring a verb to nothing. */
1727
+ /* The warning on the row above is derived from this same
1728
+ tax ID, so correcting it in the peek clears the block
1729
+ without leaving the field — which is the case for
1730
+ editing here at all. Atlas Distribution ships with an
1731
+ empty tax ID to make that path reachable. */
1732
+ onSave={(patch) =>
1733
+ setCustomers((prev) =>
1734
+ prev.map((c) =>
1735
+ c.id === customerId
1736
+ ? {
1737
+ ...c,
1738
+ taxId: patch.taxId ?? c.taxId,
1739
+ contact: patch.contact ?? c.contact,
1740
+ city: patch.city ?? c.city,
1741
+ }
1742
+ : c,
1743
+ ),
1744
+ )
1745
+ }
1665
1746
  /* A real app navigates to the customer's OWN record page —
1666
1747
  same as Ship to / Notify / Carrier below. This used to
1667
1748
  open a local drawer that re-showed the five facts the
1668
1749
  peek had just shown, so "Open" resolved to "read that
1669
1750
  again, wider" and the customer's record was unreachable
1670
1751
  from the one control that promised it. */
1671
- onOpen={() => {}}
1672
- onRemove={() => setCustomerId(null)}
1752
+ /* Clear is offered even though an order needs a bill-to
1753
+ party: withholding it would enforce nothing, since
1754
+ Change and then declining to pick reaches the same empty
1755
+ state. The tax ID that gates invoicing is checked by the
1756
+ row's `warning` above, which is where validity belongs. */
1757
+ onChange={() => { setChangingCustomer(true); setCustomerId(null); }}
1758
+ onClear={() => { setChangingCustomer(false); setCustomerId(null); }}
1673
1759
  />
1674
1760
  ) : (
1675
1761
  <Combobox
1676
1762
  options={customerOptions}
1677
- onValueChange={onPickCustomer}
1763
+ /* Pressing Change said "wrong customer" — so the picker it
1764
+ drops you into is already focused and open, and the
1765
+ correction stays one gesture. */
1766
+ autoFocus={changingCustomer}
1767
+ onValueChange={(o) => { setChangingCustomer(false); onPickCustomer(o); }}
1678
1768
  renderOptionContent={(o) => (
1679
1769
  <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
1680
1770
  <View style={{ width: 28, height: 28, borderRadius: 7, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
@@ -2015,12 +2105,12 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2015
2105
  <PartyRow
2016
2106
  role="Ship to" rec={shipToRec} options={customerOptions}
2017
2107
  placeholder="Find or create a consignee"
2018
- onPick={(o) => setShipTo(o.value)} onOpen={() => {}} onRemove={() => setShipTo(null)}
2108
+ onPick={(o) => setShipTo(o.value)} onOpen={() => {}} onUnset={() => setShipTo(null)} onSaveFacts={saveCustomerFacts}
2019
2109
  />
2020
2110
  <PartyRow
2021
2111
  role="Notify" rec={notifyRec} options={customerOptions}
2022
2112
  placeholder="Find or create a notify party"
2023
- onPick={(o) => setNotify(o.value)} onOpen={() => {}} onRemove={() => setNotify(null)}
2113
+ onPick={(o) => setNotify(o.value)} onOpen={() => {}} onUnset={() => setNotify(null)} onSaveFacts={saveCustomerFacts}
2024
2114
  />
2025
2115
  {/* A CHOICE IS ONE ROW, like every other field.
2026
2116
  `RadioPicker` renders three full-width bordered choices with
@@ -2121,7 +2211,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2121
2211
  <PartyRow
2122
2212
  role="Carrier" rec={carrierRec} options={customerOptions}
2123
2213
  placeholder="Find or create a carrier"
2124
- onPick={(o) => setCarrier(o.value)} onOpen={() => {}} onRemove={() => setCarrier(null)}
2214
+ onPick={(o) => setCarrier(o.value)} onOpen={() => {}} onUnset={() => setCarrier(null)} onSaveFacts={saveCustomerFacts}
2125
2215
  />
2126
2216
  <DetailRow label="Booking no.">
2127
2217
  <InlineTextInput value={booking} onSave={persist(setBooking)} placeholder="Add booking number…" accessibilityLabel="Booking number" />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "24.0.2",
3
+ "version": "25.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -19,6 +19,15 @@ export interface InlineStaticProps {
19
19
  /** Value weight — `medium` to emphasise a total or headline figure among plain
20
20
  * rows. Default `regular` (reads as an ordinary value, not a heading). */
21
21
  weight?: "regular" | "medium";
22
+ /**
23
+ * The value WRAPS instead of truncating — an address, a bank-account block.
24
+ *
25
+ * Single-line is the default because a record's field column reads as a column
26
+ * only if its rows are one height. Pass this where the counterpart editor is
27
+ * itself `multiline`: a truncated value beside a wrapping input is the same
28
+ * misalignment this component exists to prevent, just on the other axis.
29
+ */
30
+ multiline?: boolean;
22
31
  }
23
32
 
24
33
  /**
@@ -32,13 +41,13 @@ export interface InlineStaticProps {
32
41
  * slot, so the value takes the slack and the trailing pins right.
33
42
  */
34
43
  export function InlineStatic(props: InlineStaticProps) {
35
- const { value, placeholder, muted, tabular, align = "left", weight } = props;
44
+ const { value, placeholder, muted, tabular, align = "left", weight, multiline } = props;
36
45
  const isEmpty = value.length === 0;
37
46
  const display = isEmpty ? (placeholder ?? "—") : value;
38
47
  return (
39
48
  <View style={styles.box}>
40
49
  <Text
41
- numberOfLines={1}
50
+ numberOfLines={multiline ? undefined : 1}
42
51
  tabular={tabular}
43
52
  align={align}
44
53
  weight={weight}
@@ -61,6 +70,13 @@ const styles = StyleSheet.create({
61
70
  borderWidth: 1,
62
71
  borderColor: "transparent",
63
72
  paddingHorizontal: 8,
73
+ // The editors' vertical padding too, not just the horizontal. A single line
74
+ // hides its absence — the box is `minHeight` 40 and the text is ~20, so both
75
+ // sides land on 40 whether or not this exists — but an auto-growing input
76
+ // sizes itself to content PLUS its padding and border, so from the SECOND
77
+ // line on, a value without this sits ~14px shorter than the input that
78
+ // replaces it. Matching by coincidence at one line is not matching.
79
+ paddingVertical: 8,
64
80
  justifyContent: "center",
65
81
  },
66
82
  placeholder: { color: colors.zinc[400] },
package/src/locale.tsx CHANGED
@@ -37,11 +37,12 @@ export interface LoticsLocale {
37
37
  pagination: Required<PaginationLabels>;
38
38
  /** `SortHeader` a11y prefix + asc/desc suffixes. */
39
39
  sortHeader: Required<SortHeaderLabels>;
40
- /** `ReferenceField`'s peek footer — the go-to and detach verbs. `openLabel`
40
+ /** `ReferenceField`'s peek footer — the link verbs (change / clear), the draft
41
+ * verbs (edit / save / saving / cancel) and the go-to. `openLabel`
41
42
  * stays a per-instance PROP because it names the DESTINATION ("Open
42
43
  * customer"), which is a11y text only a caller knows; these two are the
43
44
  * VISIBLE chrome, which the pack owns. */
44
- referenceField: { open: string; remove: string };
45
+ referenceField: { open: string; change: string; clear: string; edit: string; save: string; saving: string; cancel: string };
45
46
  /** `OptionList` (and everything built on it — `Select`, `Combobox`, the
46
47
  * in-cell editors): the select-all/deselect-all links, the empty state, the
47
48
  * internal search-field placeholder, and the `Combobox` recents header. */
@@ -220,7 +221,7 @@ export const en: LoticsLocale = {
220
221
  ascending: ", ascending",
221
222
  descending: ", descending",
222
223
  },
223
- referenceField: { open: "Open", remove: "Remove" },
224
+ referenceField: { open: "Open", change: "Change", clear: "Clear", edit: "Edit", save: "Save", saving: "Saving…", cancel: "Cancel" },
224
225
  optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
225
226
  datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date", invalidTime: "Enter a complete time" },
226
227
  calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
@@ -379,7 +380,7 @@ export const vi: LoticsLocale = {
379
380
  ascending: " (tăng dần)",
380
381
  descending: " (giảm dần)",
381
382
  },
382
- referenceField: { open: "Mở", remove: "Gỡ" },
383
+ referenceField: { open: "Mở", change: "Đổi", clear: "Bỏ chọn", edit: "Sửa", save: "Lưu", saving: "Đang lưu…", cancel: "Huỷ" },
383
384
  optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
384
385
  datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ", invalidTime: "Nhập giờ đầy đủ" },
385
386
  calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
@@ -4,10 +4,11 @@ import { Button } from "./button";
4
4
  import { DetailRow, DetailTable } from "./detail_row";
5
5
  import { Divider } from "./divider";
6
6
  import { InlineEditView } from "./inline_edit";
7
+ import { InlineStatic } from "./inline_static";
7
8
  import { Popover, PopoverContent } from "./popover";
8
9
  import { DialogSectionHeadingTitle } from "./section_heading";
9
10
  import { Text } from "./text";
10
- import { TextButton } from "./text_button";
11
+ import { TextInputField } from "./text_input_field";
11
12
  import { TextLink } from "./text_link";
12
13
  import { useLoticsLocale } from "./locale";
13
14
 
@@ -22,9 +23,10 @@ import { useLoticsLocale } from "./locale";
22
23
  * already mean by it — they open a popover too.)
23
24
  *
24
25
  * ONE act on the surface, the rest one layer in:
25
- * press the value → the facts, in a popover (free, so it's the whole surface)
26
- * Open, in the peek the referenced record's own page
27
- * Remove, in the peek detach (destructive and rare)
26
+ * press the value → the facts, in a popover (free, so it's the whole surface)
27
+ * Change / Clear → the LINK: point it elsewhere, or leave it empty
28
+ * Edit → the RECORD's own facts, as a draft, committed by Save
29
+ * Open → the referenced record's page (optional; the departure)
28
30
  *
29
31
  * Open used to sit ON the field as an `InlineButton`, justified as "the common
30
32
  * act". It was neither common nor an act about the value. `actions` is for verbs
@@ -38,39 +40,161 @@ import { useLoticsLocale } from "./locale";
38
40
  * is the whole box again and the focus ring rings the field, not a narrower
39
41
  * region inside it.
40
42
  *
41
- * Remove earns its depth twice over. It is the answer to the question the PEEK
42
- * asks — "is this the right one?" — so the check and the correction are the same
43
- * gesture; and a detach link repeated down a column of references is noise in the
44
- * scan path for an act most readers never perform. Depth is not a hiding place
45
- * here: pressing a field-shaped value is the first thing anyone tries, and the
46
- * verb is plainly visible once open.
43
+ * The link verbs earn their depth twice over. They ANSWER the question the peek
44
+ * asks — "is this the right one?" — so the check and the correction are one
45
+ * gesture; and a detach control repeated down a column of references is noise in
46
+ * the scan path for an act most readers never perform. Depth is not a hiding
47
+ * place here: pressing a field-shaped value is the first thing anyone tries, and
48
+ * the verbs are plainly visible once open.
49
+ *
50
+ * Both link verbs are named for the INTENT, not the mechanism, and neither is
51
+ * `danger`: unsetting a reference is recoverable, and calling it "Remove" made it
52
+ * wear the colour reserved for destruction. `Clear` is the plain detach; `Change`
53
+ * is the same write followed by the picker, focused, because the press said
54
+ * "wrong one" and the correction should cost one gesture rather than three. Both
55
+ * are STANDARD — the footer has one shape everywhere, and a required reference
56
+ * gains nothing from hiding Clear, since Change-then-decline reaches the same
57
+ * empty state anyway.
47
58
  *
48
59
  * What it encodes is the PEEK CONTRACT — the marker, the popover's grammar, and
49
60
  * where each verb sits — not a layout convenience. It was a template composition
50
61
  * until a second surface needed it (a register's workspace drawer lists the same
51
62
  * references), which is the bar for lifting one here.
52
63
  */
64
+ export interface ReferenceFact {
65
+ label: string;
66
+ value: string;
67
+ /**
68
+ * The key this fact SAVES under. Its presence is what makes the fact
69
+ * editable — a fact without one stays read-only in the draft, which is how a
70
+ * derived value (a total, a status, a formatted id) rides along in the peek
71
+ * without pretending to be typeable.
72
+ */
73
+ name?: string;
74
+ /** A value that runs past one line — an address, a bank account block. */
75
+ multiline?: boolean;
76
+ }
77
+
53
78
  export interface ReferenceFieldProps {
54
79
  name: string;
55
80
  code?: string;
56
- facts: { label: string; value: string }[];
81
+ facts: ReferenceFact[];
57
82
  /** Announced name of the peek trigger, e.g. "Harbor Freight Lines — details". */
58
83
  accessibilityLabel: string;
59
- onOpen: () => void;
60
- openLabel: string;
61
- /** Detach omit where the link cannot be broken (a handoff's sibling record). */
62
- onRemove?: () => void;
84
+ /**
85
+ * The referenced record's own page. OPTIONAL, because a reference that can be
86
+ * corrected in place has no errand to send the reader on and a verb wired to
87
+ * nothing is worse than an absent one (it promises a destination, then spends
88
+ * the press on nothing). Omit it and the peek simply carries no Open.
89
+ */
90
+ onOpen?: () => void;
91
+ openLabel?: string;
92
+ /**
93
+ * Point this field at a DIFFERENT record. REQUIRED.
94
+ *
95
+ * Unsets the reference so the call site's picker returns, and the caller
96
+ * should hand that picker focus: the press said "wrong one", so the next move
97
+ * is choosing the right one and the correction should cost one gesture.
98
+ */
99
+ onChange: () => void;
100
+ /**
101
+ * Leave the reference EMPTY — the plain detach. REQUIRED.
102
+ *
103
+ * The SAME write as `onChange`; they differ only in FOLLOW-THROUGH, and
104
+ * nothing focuses after this one — auto-opening a picker would argue with the
105
+ * intent the press just stated. They are separate props because that
106
+ * follow-through is not inferable from the write: nothing downstream can tell
107
+ * "unset it" from "unset it, I'm about to pick another", and guessing wrong
108
+ * either steals the caret from someone who wanted an empty field or sends
109
+ * someone who wanted a swap hunting for the input.
110
+ *
111
+ * BOTH are required, including on a reference the record cannot do without.
112
+ * Hiding Clear there enforces nothing — Change, then decline to pick, lands on
113
+ * the same empty state — so it only removes the direct route to a place the
114
+ * reader can already reach, and makes the footer's shape vary for no gain.
115
+ * Whether empty is VALID is the row's business (its `warning`, its
116
+ * validation), never the peek's.
117
+ */
118
+ onClear: () => void;
119
+ /**
120
+ * Commit the draft. Receives ONLY the facts whose value CHANGED, keyed by
121
+ * `name` — never a full snapshot, so a lock or a `before_update` hook sees the
122
+ * edit the reader actually made. Present (with at least one named fact) is
123
+ * what gives the peek its Edit verb.
124
+ */
125
+ onSave?: (patch: Record<string, string>) => void | Promise<void>;
63
126
  }
64
127
 
65
128
  export function ReferenceField(props: ReferenceFieldProps) {
66
- const { name, code, facts, accessibilityLabel, onOpen, openLabel, onRemove } = props;
67
- // The peek's two verbs are the component's OWN chrome, so they come from the
129
+ const { name, code, facts, accessibilityLabel, onOpen, openLabel, onChange, onClear, onSave } = props;
130
+ // The peek's verbs are the component's OWN chrome, so they come from the
68
131
  // pack — hardcoding them shipped "Open"/"Remove" into every localized app.
69
132
  const t = useLoticsLocale().referenceField;
70
133
  const anchor = useRef<View>(null);
71
134
  const [peekOpen, setPeekOpen] = useState(false);
135
+ const [draft, setDraft] = useState<Record<string, string> | null>(null);
136
+ const [saving, setSaving] = useState(false);
137
+ const [error, setError] = useState<string | null>(null);
138
+ const editing = draft !== null;
139
+ const editable = onSave != null && facts.some((f) => f.name != null);
140
+
141
+ const openDraft = () => {
142
+ const seed: Record<string, string> = {};
143
+ for (const f of facts) if (f.name != null) seed[f.name] = f.value;
144
+ setError(null);
145
+ setDraft(seed);
146
+ };
147
+ const closeDraft = () => {
148
+ setDraft(null);
149
+ setError(null);
150
+ };
151
+ // ONLY what changed. A full snapshot would clobber a concurrent edit to a
152
+ // fact this reader never touched, and would fire every `before_update` hook
153
+ // on the record rather than the ones the edit actually earned.
154
+ const changed = (d: Record<string, string>): Record<string, string> => {
155
+ const patch: Record<string, string> = {};
156
+ for (const f of facts) if (f.name != null && d[f.name] !== f.value) patch[f.name] = d[f.name] ?? "";
157
+ return patch;
158
+ };
159
+ const dirty = editing && Object.keys(changed(draft)).length > 0;
160
+
161
+ const save = async () => {
162
+ if (draft == null || onSave == null) return;
163
+ setSaving(true);
164
+ setError(null);
165
+ try {
166
+ await onSave(changed(draft));
167
+ setDraft(null);
168
+ } catch (e) {
169
+ // The draft SURVIVES a failed save — discarding what someone just typed
170
+ // because the network refused it is the one unforgivable thing a form
171
+ // does. They retry, or they cancel; the component never decides.
172
+ setError(e instanceof Error ? e.message : String(e));
173
+ } finally {
174
+ setSaving(false);
175
+ }
176
+ };
177
+
72
178
  return (
73
- <Popover open={peekOpen} onOpenChange={setPeekOpen} triggerRef={anchor} side="bottom" align="start">
179
+ <Popover
180
+ open={peekOpen}
181
+ /* A DRAFT PINS THE POPOVER OPEN. Outside-press and Escape are this
182
+ surface's dismissal, and dismissal is silent — so while a draft is
183
+ open they would throw away typing with no undo and no trace. This is
184
+ the whole reason a peek could not hold self-persisting inline editors
185
+ (blur and dismiss are one gesture there); a draft with an explicit
186
+ Save has no such collision, PROVIDED the draft outranks the dismissal.
187
+ Cancel and Save are then the only ways out, which is what makes them
188
+ worth rendering. */
189
+ onOpenChange={(next) => {
190
+ if (!next && editing) return;
191
+ setPeekOpen(next);
192
+ if (!next) closeDraft();
193
+ }}
194
+ triggerRef={anchor}
195
+ side="bottom"
196
+ align="start"
197
+ >
74
198
  {/* THE KIT'S FIELD, not a lookalike. This was hand-rolled — a Pressable
75
199
  wearing copies of the field's border, radius, height and hover — and it
76
200
  promptly fell out of step the moment the kit's field changed: it kept a
@@ -120,57 +244,133 @@ export function ReferenceField(props: ReferenceFieldProps) {
120
244
  <PopoverContent style={{ width: 320 }} disableBodyScroll>
121
245
  <View style={{ gap: 12 }}>
122
246
  <DialogSectionHeadingTitle description={code}>{name}</DialogSectionHeadingTitle>
123
- {/* PLAIN `Text`, not `InlineStatic`and `minHeight` 28, `DetailRow`'s
124
- own default, instead of the `DetailTable`'s 40.
247
+ {/* ONE geometry for both modes the table's own 40px band, which is
248
+ `CONTROL_HEIGHT` and exactly what `TextInputField` renders at. The
249
+ read row therefore RESERVES the space its editor will need, and
250
+ pressing Edit changes the value cell's species without moving a
251
+ single label.
125
252
 
126
- Both come from one fact: this grid has NO editors. `InlineStatic`
127
- exists to align pixel-for-pixel with the `Inline*` controls it
128
- hard-sets `INLINE_CONTROL_HEIGHT` so a computed total sits flush
129
- beside editable rows in a record. Here there is nothing to sit flush
130
- WITH, so it only reserved a 40px control band for a 20px value:
131
- three facts, 60px of air, in a popover whose whole job is a glance.
132
- `minHeight` alone does not fix it (the value's own box still forces
133
- 40) and `Text` alone does not either (the table's band re-imposes
134
- it) the row is the max of the two, so both have to go. */}
135
- <DetailTable labelWidth={88} minHeight={28}>
136
- {facts.map((f) => (
137
- <DetailRow key={f.label} label={f.label} flat>
138
- <Text size="sm">{f.value || "—"}</Text>
139
- </DetailRow>
140
- ))}
253
+ This read at 28 for a while, on the argument that the grid had no
254
+ editors and a glance surface should not buy a control band for a
255
+ 20px value. That premise expired the moment the peek gained a
256
+ draft, and the constant outlived it: 28 40 grew every row by 12px
257
+ and re-centred every label by 6px inside it, so by the third fact
258
+ the reader's eye had moved 24px to follow text it was already
259
+ reading. A view↔edit toggle reserves the input's height; the 36px
260
+ of extra air across three facts is what that costs, and it is
261
+ cheaper than making someone re-find their place on every press. */}
262
+ <DetailTable labelWidth={88}>
263
+ {facts.map((f) =>
264
+ editing && f.name != null ? (
265
+ <DetailRow key={f.label} label={f.label}>
266
+ <TextInputField
267
+ value={draft[f.name] ?? ""}
268
+ onChangeText={(v) => setDraft({ ...draft, [f.name as string]: v })}
269
+ multiline={f.multiline}
270
+ autoGrow={f.multiline}
271
+ disabled={saving}
272
+ accessibilityLabel={f.label}
273
+ />
274
+ </DetailRow>
275
+ ) : (
276
+ <DetailRow key={f.label} label={f.label}>
277
+ {/* `InlineStatic`, NOT a bare `Text`. An input's words start at
278
+ `CONTROL_TEXT_INSET` — 1px border plus 8px padding — and a
279
+ raw Text starts at 0, so pressing Edit slid every value 9px
280
+ right. This is the component built to sit flush beside
281
+ editors, border and all, and reaching for it means the
282
+ alignment survives the control geometry changing. Copying
283
+ the box here instead would drift the first time it does. */}
284
+ <InlineStatic value={f.value} multiline={f.multiline} />
285
+ </DetailRow>
286
+ ),
287
+ )}
141
288
  </DetailTable>
142
- {/* THE FOOTER destructive LEFT, go-to RIGHT, the kit's convention.
143
- Open repeats the verb on the surface deliberately: the popover now
144
- covers the button that opened it, and having read the facts, "take me
145
- there" is the next move. That is the shape `Peek` prescribes — a
146
- summary with one action to the full record. Remove closes the peek
147
- first, because the surface it is anchored to is about to stop
148
- existing. */}
289
+ {error ? <Text size="sm" color="danger">{error}</Text> : null}
290
+ {/* THE FOOTER — every verb is a `Button`, at ONE altitude. The peek is
291
+ dialog-scale (its title already takes the dialog rung), and a
292
+ dialog's verbs are buttons; mixing weights here made the reader
293
+ rank three acts that are simply three acts.
294
+
295
+ CHANGE, not Remove, and not `danger`. The mechanism is a detach,
296
+ but nobody arrives at it wanting an empty field — they arrive
297
+ having read the facts and decided this is the wrong record, and
298
+ what follows is the picker. Naming it for the mechanism made a
299
+ recoverable correction wear the colour reserved for destruction.
300
+ It is the answer to the question the peek asks — "is this the right
301
+ one?" — so it sits FIRST, at the left, where that answer belongs.
302
+
303
+ OPEN stays right and OPTIONAL: it is the departure, and on a peek
304
+ whose facts can be corrected in place, leaving is the rarest move.
305
+
306
+ The whole footer is conditional — with all three verbs optional, an
307
+ edit-only reference would otherwise draw a rule under the facts and
308
+ fence off an empty band, which is chrome asserting a structure that
309
+ is not there. */}
149
310
  <Divider />
311
+ {/* THE DRAFT'S FOOTER REPLACES the peek's, it does not join it. Change
312
+ and Open are moves AWAY from an unsaved draft — one detaches the
313
+ record being edited, one navigates off it — so offering either here
314
+ would be offering to lose the typing. Cancel and Save are the only
315
+ two exits, which is also what the pinned popover promised. */}
316
+ {editing ? (
317
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
318
+ <View style={{ flex: 1 }} />
319
+ <Button title={t.cancel} color="secondary" disabled={saving} onPress={closeDraft} />
320
+ {/* Disabled until something DIFFERS: with nothing to send, a save
321
+ is a write that fires the record's hooks and bumps its
322
+ modified stamp to record that nobody changed anything. */}
323
+ <Button
324
+ title={saving ? t.saving : t.save}
325
+ color="primary"
326
+ disabled={!dirty || saving}
327
+ onPress={() => void save()}
328
+ />
329
+ </View>
330
+ ) : (
150
331
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
151
- {onRemove ? (
152
- // `TextButton color="danger"` this act wanted the component, not a
153
- // Button wearing three corrections. It went `danger` (solid, too
154
- // loud for the rarest verb here) → `danger-secondary` (a ghost, so
155
- // its ink sat a padding's width inside the column every fact label
156
- // starts on) → `+ flush` (that geometry, patched). A text-weight
157
- // destructive act aligned to a column of text IS this component: red
158
- // ink, no fill, its own bleed, and a hover wash and focus ring a bare
159
- // pressable Text never had.
160
- //
161
- // Pairing a text action with the filled `secondary` "Open" is the
162
- // standard footer shape, not a mismatch Material and HIG both put
163
- // the quiet/destructive verb at text weight beside a filled primary.
164
- // No icon: an beside "Remove" says it twice, and the glyph alone
165
- // made this read as the popover's dismiss rather than a decision.
166
- <TextButton color="danger" onPress={() => { setPeekOpen(false); onRemove(); }}>{t.remove}</TextButton>
167
- ) : null}
332
+ {/* LEFT PAIR acts on the LINK — which record this points at. Both
333
+ are unconditional: the peek always offers "point it elsewhere"
334
+ and "leave it empty", so its footer has ONE shape everywhere
335
+ instead of four depending on which callbacks a call site
336
+ remembered. */}
337
+ <Button
338
+ title={t.change}
339
+ color="secondary"
340
+ accessibilityLabel={`${t.change} ${name}`}
341
+ onPress={() => { setPeekOpen(false); onChange(); }}
342
+ />
343
+ {/* No fill Clear is the least-reached verb here, and the one whose
344
+ result the reader is least likely to want by accident, so it
345
+ carries the least weight of the four. */}
346
+ <Button
347
+ title={t.clear}
348
+ accessibilityLabel={`${t.clear} ${name}`}
349
+ onPress={() => { setPeekOpen(false); onClear(); }}
350
+ />
351
+ {/* The spacer is the SEAM between what the two pairs touch: left the
352
+ link, right the record it points at. Without that split, Open's
353
+ position is just "pushed over". */}
168
354
  <View style={{ flex: 1 }} />
355
+ {/* RIGHT PAIR acts on the RECORD the link points at — correct its
356
+ data, or go to it. Edit takes the ONE filled-dark rung because it
357
+ is the only verb here that leads to a commit, and it hands that
358
+ rung straight to Save when the draft opens: one primary per mode,
359
+ never two. Every other verb stays `secondary` and FILLED — a
360
+ fill-less Button shows no box, so its ink sits a padding inside
361
+ its own edge, and one boxless label in a row of boxes reads as
362
+ indented (the geometry that made `TextButton` exist). */}
363
+ {editable ? (
364
+ <Button title={t.edit} color="primary" accessibilityLabel={`${t.edit} — ${name}`} onPress={openDraft} />
365
+ ) : null}
169
366
  {/* `openLabel` names the DESTINATION ("Open customer"): a page carries
170
367
  four of these peeks, and four buttons announcing a bare "Open"
171
368
  are four controls a screen reader cannot tell apart. */}
172
- <Button title={t.open} color="secondary" accessibilityLabel={openLabel} onPress={() => { setPeekOpen(false); onOpen(); }} />
369
+ {onOpen ? (
370
+ <Button title={t.open} color="secondary" accessibilityLabel={openLabel} onPress={() => { setPeekOpen(false); onOpen(); }} />
371
+ ) : null}
173
372
  </View>
373
+ )}
174
374
  </View>
175
375
  </PopoverContent>
176
376
  </Popover>
@@ -52,6 +52,12 @@ interface TextInputFieldProps extends RNTextInputProps {
52
52
  "aria-autocomplete"?: "none" | "inline" | "list" | "both";
53
53
  }
54
54
 
55
+ /** The input box's own vertical padding and border, named so the auto-grow math
56
+ * and the stylesheet cannot drift apart — the height must cover content +
57
+ * padding + border, because the box is `border-box`. */
58
+ const INPUT_PADDING_Y = 8;
59
+ const INPUT_BORDER_WIDTH = 1;
60
+
55
61
  export function TextInputField(props: TextInputFieldProps) {
56
62
  const locale = useLoticsLocale();
57
63
  const {
@@ -105,8 +111,15 @@ export function TextInputField(props: TextInputFieldProps) {
105
111
  minLines: numberOfLines ?? 1,
106
112
  });
107
113
 
114
+ // `containerHeight` is the CONTENT's height, and the box is `border-box`, so
115
+ // growing to fit it means adding the padding AND the border. Adding only the
116
+ // padding left an auto-grown field 2px short of its own text — with
117
+ // `overflow: hidden` on the non-scrolling path, the last line was silently
118
+ // clipped by exactly the border. The same trap `CONTROL_TEXT_INSET` documents
119
+ // on the horizontal axis: read the padding, forget the border, land 1px per
120
+ // side wrong.
108
121
  const height = autoGrow
109
- ? Math.max(minHeight ?? 40, autoGrowResult.containerHeight + 16)
122
+ ? Math.max(minHeight ?? 40, autoGrowResult.containerHeight + INPUT_PADDING_Y * 2 + INPUT_BORDER_WIDTH * 2)
110
123
  : minHeight;
111
124
 
112
125
  const editable = !!(!disabled || inputProps.editable);
@@ -225,7 +238,7 @@ const SHORTCUT_INSET = 12;
225
238
  const styles = StyleSheet.create({
226
239
  input: {
227
240
  borderRadius: CONTROL_RADIUS,
228
- borderWidth: 1,
241
+ borderWidth: INPUT_BORDER_WIDTH,
229
242
  borderColor: colors.border,
230
243
  // The input surface is WHITE on ANY background — an input is an open well
231
244
  // to type into, and on a tinted panel (an inset fill editor, a toned card)
@@ -234,7 +247,7 @@ const styles = StyleSheet.create({
234
247
  // overrides via `style` (it lands after this in the array).
235
248
  backgroundColor: colors.white,
236
249
  height: 40,
237
- paddingVertical: 8,
250
+ paddingVertical: INPUT_PADDING_Y,
238
251
  paddingHorizontal: 8,
239
252
  fontFamily: fontFamilyRegular,
240
253
  letterSpacing: -0.4,