@lotics/ui 24.0.2 → 25.1.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
@@ -720,7 +720,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
720
720
  just text. **`InlineSelect` is single OR multi** — pass `multi` for a tag SET (`value: T[]`,
721
721
  commits the new set on popover-CLOSE; selected tags render as badges via `renderSelected`),
722
722
  mirroring `Select`'s `multi` axis; there is NO separate tag component. Both modes take
723
- `allowCustom` (a create-a-tag/option row) + `searchable`. **`InlineMemberSelect` takes `avatarOnly`**
723
+ `allowCustom` (a create-a-tag/option row) + `searchable`, plus **`autoFocus`** to open the list
724
+ on mount — for the picker a `ReferenceField`'s `Change` just dropped the reader into, so the
725
+ correction stays one gesture; leave it off for a field that is merely empty, since stealing the
726
+ list open on load is a different act. **`InlineMemberSelect` takes `avatarOnly`**
724
727
  — the resting display is the bare AVATAR (no name/chevron, a dashed "add" ghost when unset) for a
725
728
  DENSE row where the name won't fit (a task-row assignee); the dropdown rows still show avatar + name.
726
729
  `InlineDatePicker` takes a **`tone`**
@@ -748,7 +751,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
748
751
  EXACTLY (height, padding, 1px transparent border) so a non-editable field — a computed
749
752
  total, a system ID, a synced/locked value — aligns pixel-for-pixel in the same column;
750
753
  non-interactive, NOT a disabled input; `muted`/`tabular`/`align="right"` for a number
751
- column, `weight="medium"` to emphasise a total among plain rows.
754
+ column, `weight="medium"` to emphasise a total among plain rows, `multiline` to WRAP rather
755
+ than truncate (single-line is the default, because a field column reads as a column only if
756
+ its rows are one height — pass it where the counterpart editor is itself `multiline`, or a
757
+ truncated value beside a wrapping input reintroduces the misalignment on the other axis).
758
+ **Reach for it over a bare `Text` in any grid that has editors in some state**: an input's
759
+ words start at `CONTROL_TEXT_INSET` (1px border + 8px padding) and a raw `Text` starts at 0,
760
+ so a read/edit toggle slides every value 9px sideways.
752
761
  - **`sequence`** — `Sequence` + `SequenceItem` (+ `SEQUENCE_INSET`): an ORDERED list whose ORDER
753
762
  IS THE DATA — a route's stops, an approval chain, a set of legs — drawn as a connected rail so
754
763
  the sequence reads without a label saying "first"/"then". Reach for it when a thing has a
@@ -823,11 +832,48 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
823
832
  - **`reference_field`** — `ReferenceField`: a reference to ANOTHER RECORD, rendered as a
824
833
  FIELD VALUE — the kit's inline-editor surface (so a pointer sits in the value column
825
834
  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
835
+ `name` + optional `code` + `facts`, plus the verb callbacks below. The verbs live INSIDE the
836
+ peek, never on the field, and they are ALL `Button`s at one altitude — a peek is
837
+ dialog-scale and a dialog's verbs are buttons, so mixing weights only made the reader rank
838
+ four acts that are four acts. The row splits by WHAT EACH TOUCHES, and the spacer is that
839
+ seam: **left, the LINK** `Change` (`onChange`, `secondary`) points the field at a different
840
+ record, named for the intent and deliberately not `danger` since a recoverable correction must
841
+ not wear the colour reserved for destruction; `Clear` (`onClear`, NO fill — the least-reached
842
+ verb of the four) leaves it empty. **Both are REQUIRED**, so the footer has one shape
843
+ everywhere; a reference the record cannot do without gains nothing from hiding Clear, because
844
+ Change and then declining to pick reaches the same empty state. **Right, the RECORD it points at** — `Edit` (`primary`, the one filled-dark rung,
845
+ handed to `Save` when the draft opens) and `Open` (`secondary`, rightmost, OPTIONAL, the
846
+ departure — not prioritized, because on a peek whose facts are correctable in place leaving is
847
+ the rarest move). Every verb is optional and the footer renders only when it HAS one, so an
848
+ edit-only reference draws no rule under nothing.
849
+ **Both unset — they differ only in FOLLOW-THROUGH.** `Clear` is the plain detach (nothing
850
+ focused); `Change` is the same write plus the caller's focus handoff. They are separate verbs
851
+ because the follow-through is not inferable from the write: nothing downstream can tell "unset
852
+ it" from "unset it, I'm about to pick another". Whether empty is VALID is the row's business — its
853
+ `warning`, its validation — never the peek's.
854
+ **Hand the picker focus after `Change`, never after `Clear`.** Both land on your empty state.
855
+ Change said "wrong one", so open the picker focused (`Combobox`'s `autoFocus` both focuses AND
856
+ opens) and the correction stays one gesture instead of three; Clear said "there is none", so
857
+ focusing would argue with the press and leave a menu to dismiss. Track it as state — "empty"
858
+ and "empty because Change was just pressed" are indistinguishable otherwise, and a field that
859
+ starts empty must not steal the caret on load.
860
+ Labels come from the locale pack (`referenceField.open` / `.change` / `.clear` / `.edit` /
861
+ `.save` / `.saving` / `.cancel`), while `openLabel` stays a prop because it names the
862
+ DESTINATION for a screen reader, which only the caller knows. **EDIT IN THE PEEK** (`onSave`):
863
+ a fact carrying a `name` is editable and that key is what it saves under (omit `name` for a
864
+ derived value that rides along read-only); `multiline` for an address or an account block;
865
+ `type: "date"` swaps the draft's text input for a `DatePicker` and formats the read value —
866
+ the fact's `value` stays the canonical ISO string on both sides, so a date is never edited as
867
+ free text and the format ambiguity never reaches the record.
868
+ `Edit` swaps the SAME grid's value cells for inputs — a DRAFT, so nothing commits until `Save`,
869
+ which fires `onSave` with **only the facts that CHANGED** (never a snapshot, so a lock or
870
+ `before_update` sees the real edit). This is what lets a peek hold editors at all: a
871
+ self-persisting inline editor cannot, because there blur and dismiss are ONE gesture — an open
872
+ draft instead PINS the popover (outside-press and Escape stop dismissing), making
873
+ `Cancel`/`Save` the only exits, and the draft's footer REPLACES the peek's rather than joining
874
+ it (Change, Clear and Open are all moves away from unsaved typing). A failed save KEEPS the
875
+ draft. `Save` is disabled until something differs, since an empty write still fires the
876
+ record's hooks and bumps its modified stamp. The value is a `TextLink` marker
831
877
  (underlined, neutral) because the press opens a summary rather than navigating. A
832
878
  reference is a FIELD, not a section — one pointer to one other row never earns a heading
833
879
  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,22 @@ 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;
130
+ /** ISO. A date fact, so the peek's draft has one non-text editor to render —
131
+ * left as text it would take whatever shape the reader typed. */
132
+ since: string;
127
133
  }
128
134
 
129
135
  // Atlas ships WITHOUT a tax ID — attach it to see the billing gate + the
130
136
  // inline Tax ID fix-up in the Customer section.
131
137
  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" },
138
+ { 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", since: "2019-03-14" },
139
+ { 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", since: "2021-11-02" },
140
+ { 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", since: "2023-06-19" },
141
+ { id: "cus_04", name: "Atlas Distribution", code: "KH-0117", taxId: "", contact: "Tom Becker", city: "Hamburg", address: "Grosser Grasbrook 9, 20457 Hamburg, Germany", since: "2018-01-30" },
142
+ { id: "cus_05", name: "Bluewater Logistics", code: "KH-0294", taxId: "0312004455", contact: "Lena Fischer", city: "Antwerp", address: "Noorderlaan 127, 2030 Antwerpen, Belgium", since: "2022-09-08" },
137
143
  ];
138
144
 
139
145
  const TAX_ID_RE = /^\d{10}(\d{3})?$/;
@@ -704,15 +710,24 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
704
710
  /** A party ROW — a reference when one is attached, the find-or-create picker when
705
711
  * not. The two states share the row so the field never moves; the picker is the
706
712
  * field's EMPTY state, not a different kind of surface. */
707
- function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onRemove }: {
713
+ function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onUnset, onSaveFacts }: {
708
714
  role: string;
709
715
  rec: Customer | null;
710
716
  options: PickerOption<string, Customer>[];
711
717
  placeholder: string;
712
718
  onPick: (opt: PickerOption<string, Customer>) => void;
713
719
  onOpen: () => void;
714
- onRemove: () => void;
720
+ onUnset: () => void;
721
+ onSaveFacts: (id: string, patch: Record<string, string>) => void;
715
722
  }) {
723
+ // THE FOCUS HANDOFF, and only for Change. Both verbs unset the reference, so
724
+ // both land on the picker below — but they arrive meaning opposite things.
725
+ // Change said "wrong one", and the next move is choosing the right one, so the
726
+ // picker opens focused and the correction stays ONE gesture instead of three
727
+ // (press, hunt for the input, click it). Clear said "there is none": opening a
728
+ // picker there would argue with the press, and leave a menu the reader has to
729
+ // dismiss to get the empty state they just asked for.
730
+ const [changing, setChanging] = useState(false);
716
731
  return (
717
732
  <DetailRow label={role}>
718
733
  {rec ? (
@@ -720,17 +735,26 @@ function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onRemove }:
720
735
  name={rec.name}
721
736
  code={rec.code}
722
737
  facts={[
723
- { label: "Contact", value: rec.contact },
724
- { label: "City", value: rec.city },
725
- { label: "Tax ID", value: rec.taxId },
738
+ { label: "Contact", value: rec.contact, name: "contact" },
739
+ { label: "City", value: rec.city, name: "city" },
740
+ { label: "Tax ID", value: rec.taxId, name: "taxId" },
741
+ { label: "Address", value: rec.address, name: "address", multiline: true },
742
+ { label: "Customer since", value: rec.since, name: "since", type: "date" },
726
743
  ]}
727
744
  accessibilityLabel={`${rec.name} — details`}
728
745
  openLabel={`Open ${rec.name}`}
729
746
  onOpen={onOpen}
730
- onRemove={onRemove}
747
+ onChange={() => { setChanging(true); onUnset(); }}
748
+ onClear={() => { setChanging(false); onUnset(); }}
749
+ onSave={(patch) => onSaveFacts(rec.id, patch)}
731
750
  />
732
751
  ) : (
733
- <Combobox options={options} onValueChange={onPick} reflectSelection={false}>
752
+ <Combobox
753
+ options={options}
754
+ autoFocus={changing}
755
+ onValueChange={(o) => { setChanging(false); onPick(o); }}
756
+ reflectSelection={false}
757
+ >
734
758
  <ComboboxInput icon="search" placeholder={placeholder} accessibilityLabel={role} />
735
759
  <ComboboxContent emptyText="No match" />
736
760
  </Combobox>
@@ -818,6 +842,12 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
818
842
  // the Customer section (that's what un-gates invoicing).
819
843
  const [customers, setCustomers] = useState<Customer[]>(KNOWN_CUSTOMERS);
820
844
  const [customerId, setCustomerId] = useState<string | null>("cus_02");
845
+ // Set by Change, consumed by the picker's `autoFocus`, cleared on pick. It has
846
+ // to be state rather than a derived value: "empty" and "empty BECAUSE the
847
+ // reader just pressed Change" look identical on the record, and only the
848
+ // second should steal focus — a field that starts empty must not grab the
849
+ // caret on load.
850
+ const [changingCustomer, setChangingCustomer] = useState(false);
821
851
  const customer = customers.find((c) => c.id === customerId) ?? null;
822
852
 
823
853
  // ── facts + details (all inline-persisted)
@@ -835,7 +865,11 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
835
865
  // record arrives at, and the surface every compaction idea has to be judged
836
866
  // against — measure any change here, not on a four-field group.
837
867
  const [shipTo, setShipTo] = useState<string | null>("cus_03");
838
- const [notify, setNotify] = useState<string | null>(null);
868
+ // Seeded so the ONE row carrying all four verbs is reachable at rest. Its own
869
+ // Clear returns it to the empty picker in a press, so the empty state this
870
+ // used to demonstrate at rest is still one gesture away — and now the full
871
+ // footer is too, which it never was.
872
+ const [notify, setNotify] = useState<string | null>("cus_01");
839
873
  const [carrier, setCarrier] = useState<string | null>("cus_05");
840
874
  const [docType, setDocType] = useState("original");
841
875
  const [waybill, setWaybill] = useState("WB-4471902");
@@ -856,6 +890,24 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
856
890
  ]);
857
891
  const [dropPoint, setDropPoint] = useState("");
858
892
  const [scheduleNote, setScheduleNote] = useState("delayed");
893
+ // One writer for every party's facts — the peek's draft hands back only the
894
+ // keys that CHANGED, so each is applied over the record it names and the rest
895
+ // of that customer is left alone.
896
+ const saveCustomerFacts = (id: string, patch: Record<string, string>) =>
897
+ setCustomers((prev) =>
898
+ prev.map((c) =>
899
+ c.id === id
900
+ ? {
901
+ ...c,
902
+ contact: patch.contact ?? c.contact,
903
+ city: patch.city ?? c.city,
904
+ taxId: patch.taxId ?? c.taxId,
905
+ address: patch.address ?? c.address,
906
+ since: patch.since ?? c.since,
907
+ }
908
+ : c,
909
+ ),
910
+ );
859
911
  const shipToRec = customers.find((c) => c.id === shipTo) ?? null;
860
912
  const notifyRec = customers.find((c) => c.id === notify) ?? null;
861
913
  const carrierRec = customers.find((c) => c.id === carrier) ?? null;
@@ -1191,6 +1243,10 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1191
1243
  taxId: custDraft.taxId.trim(),
1192
1244
  contact: custDraft.contact.trim(),
1193
1245
  city: custDraft.city.trim(),
1246
+ // Not collected at create — a new party is attached from three fields and
1247
+ // the rest is filled in later, which is what the peek's Edit is for.
1248
+ address: "",
1249
+ since: "",
1194
1250
  };
1195
1251
  setCustomers((prev) => [...prev, c]);
1196
1252
  setCustomerId(c.id);
@@ -1655,26 +1711,67 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1655
1711
  <ReferenceField
1656
1712
  name={customer.name}
1657
1713
  code={customer.code}
1714
+ /* A fact carrying a `name` is EDITABLE — the key it saves
1715
+ under. The three here are the customer's own columns; a
1716
+ derived one (a balance, a rating) would omit `name` and
1717
+ ride along read-only. */
1658
1718
  facts={[
1659
- { label: "Tax ID", value: customer.taxId },
1660
- { label: "Contact", value: customer.contact },
1661
- { label: "City", value: customer.city },
1719
+ { label: "Tax ID", value: customer.taxId, name: "taxId" },
1720
+ { label: "Contact", value: customer.contact, name: "contact" },
1721
+ { label: "City", value: customer.city, name: "city" },
1722
+ { label: "Address", value: customer.address, name: "address", multiline: true },
1723
+ { label: "Customer since", value: customer.since, name: "since", type: "date" },
1662
1724
  ]}
1663
1725
  accessibilityLabel={`${customer.name} — details`}
1664
- openLabel="Open customer"
1726
+ /* NO `onOpen`. The peek carries every fact this record
1727
+ holds about the customer AND can correct them, so
1728
+ "Open" resolved to "read that again, wider" — the exact
1729
+ thing the deleted customer drawer was doing. The party
1730
+ rows below still pass it: a carrier's page has related
1731
+ lists a peek can never hold, and that is a real errand.
1732
+ The prop is optional so each call site answers for
1733
+ itself instead of wiring a verb to nothing. */
1734
+ /* The warning on the row above is derived from this same
1735
+ tax ID, so correcting it in the peek clears the block
1736
+ without leaving the field — which is the case for
1737
+ editing here at all. Atlas Distribution ships with an
1738
+ empty tax ID to make that path reachable. */
1739
+ onSave={(patch) =>
1740
+ setCustomers((prev) =>
1741
+ prev.map((c) =>
1742
+ c.id === customerId
1743
+ ? {
1744
+ ...c,
1745
+ taxId: patch.taxId ?? c.taxId,
1746
+ contact: patch.contact ?? c.contact,
1747
+ city: patch.city ?? c.city,
1748
+ }
1749
+ : c,
1750
+ ),
1751
+ )
1752
+ }
1665
1753
  /* A real app navigates to the customer's OWN record page —
1666
1754
  same as Ship to / Notify / Carrier below. This used to
1667
1755
  open a local drawer that re-showed the five facts the
1668
1756
  peek had just shown, so "Open" resolved to "read that
1669
1757
  again, wider" and the customer's record was unreachable
1670
1758
  from the one control that promised it. */
1671
- onOpen={() => {}}
1672
- onRemove={() => setCustomerId(null)}
1759
+ /* Clear is offered even though an order needs a bill-to
1760
+ party: withholding it would enforce nothing, since
1761
+ Change and then declining to pick reaches the same empty
1762
+ state. The tax ID that gates invoicing is checked by the
1763
+ row's `warning` above, which is where validity belongs. */
1764
+ onChange={() => { setChangingCustomer(true); setCustomerId(null); }}
1765
+ onClear={() => { setChangingCustomer(false); setCustomerId(null); }}
1673
1766
  />
1674
1767
  ) : (
1675
1768
  <Combobox
1676
1769
  options={customerOptions}
1677
- onValueChange={onPickCustomer}
1770
+ /* Pressing Change said "wrong customer" — so the picker it
1771
+ drops you into is already focused and open, and the
1772
+ correction stays one gesture. */
1773
+ autoFocus={changingCustomer}
1774
+ onValueChange={(o) => { setChangingCustomer(false); onPickCustomer(o); }}
1678
1775
  renderOptionContent={(o) => (
1679
1776
  <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
1680
1777
  <View style={{ width: 28, height: 28, borderRadius: 7, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
@@ -2015,12 +2112,12 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2015
2112
  <PartyRow
2016
2113
  role="Ship to" rec={shipToRec} options={customerOptions}
2017
2114
  placeholder="Find or create a consignee"
2018
- onPick={(o) => setShipTo(o.value)} onOpen={() => {}} onRemove={() => setShipTo(null)}
2115
+ onPick={(o) => setShipTo(o.value)} onOpen={() => {}} onUnset={() => setShipTo(null)} onSaveFacts={saveCustomerFacts}
2019
2116
  />
2020
2117
  <PartyRow
2021
2118
  role="Notify" rec={notifyRec} options={customerOptions}
2022
2119
  placeholder="Find or create a notify party"
2023
- onPick={(o) => setNotify(o.value)} onOpen={() => {}} onRemove={() => setNotify(null)}
2120
+ onPick={(o) => setNotify(o.value)} onOpen={() => {}} onUnset={() => setNotify(null)} onSaveFacts={saveCustomerFacts}
2024
2121
  />
2025
2122
  {/* A CHOICE IS ONE ROW, like every other field.
2026
2123
  `RadioPicker` renders three full-width bordered choices with
@@ -2121,7 +2218,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2121
2218
  <PartyRow
2122
2219
  role="Carrier" rec={carrierRec} options={customerOptions}
2123
2220
  placeholder="Find or create a carrier"
2124
- onPick={(o) => setCarrier(o.value)} onOpen={() => {}} onRemove={() => setCarrier(null)}
2221
+ onPick={(o) => setCarrier(o.value)} onOpen={() => {}} onUnset={() => setCarrier(null)} onSaveFacts={saveCustomerFacts}
2125
2222
  />
2126
2223
  <DetailRow label="Booking no.">
2127
2224
  <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.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -13,6 +13,16 @@ import { useLoticsLocale } from "./locale";
13
13
 
14
14
  interface InlineSelectBaseProps<T extends string, D = unknown> {
15
15
  options: PickerOption<T, D>[];
16
+ /**
17
+ * Open the list on mount.
18
+ *
19
+ * For a picker that REPLACED something the reader just dismissed — a
20
+ * `ReferenceField` whose `onChange` fired, which means "wrong one, I'm about
21
+ * to pick another". Landing them on a closed picker charges a second press to
22
+ * resume a correction they already started. Leave it off for a field that is
23
+ * merely empty: stealing the list open on load is not the same act.
24
+ */
25
+ autoFocus?: boolean;
16
26
  /** Custom option content in the dropdown (icon + label, two-line, a badge…).
17
27
  * Omit for a plain label list — both render through the same `OptionList`. */
18
28
  renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
@@ -119,9 +129,9 @@ function InlineSelectShell(props: {
119
129
  }
120
130
 
121
131
  export function InlineSelect<T extends string, D = unknown>(props: InlineSelectProps<T, D>) {
122
- const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, variant, actions } = props;
132
+ const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, variant, actions, autoFocus = false } = props;
123
133
  const labels = useLoticsLocale().inline;
124
- const [open, setOpen] = useState(false);
134
+ const [open, setOpen] = useState(autoFocus);
125
135
  const [saving, setSaving] = useState(false);
126
136
  const [error, setError] = useState<string | null>(null);
127
137
  const [draft, setDraft] = useState<T[]>(props.multi ? props.value : []);
@@ -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" },
@@ -1,15 +1,18 @@
1
1
  import { useRef, useState } from "react";
2
2
  import { View } from "react-native";
3
3
  import { Button } from "./button";
4
+ import { DatePicker } from "./date_picker";
4
5
  import { DetailRow, DetailTable } from "./detail_row";
6
+ import { formatDate } from "./format_date";
5
7
  import { Divider } from "./divider";
6
8
  import { InlineEditView } from "./inline_edit";
9
+ import { InlineStatic } from "./inline_static";
7
10
  import { Popover, PopoverContent } from "./popover";
8
11
  import { DialogSectionHeadingTitle } from "./section_heading";
9
12
  import { Text } from "./text";
10
- import { TextButton } from "./text_button";
13
+ import { TextInputField } from "./text_input_field";
11
14
  import { TextLink } from "./text_link";
12
- import { useLoticsLocale } from "./locale";
15
+ import { useLocaleTag, useLoticsLocale } from "./locale";
13
16
 
14
17
  /**
15
18
  * A REFERENCE to another record, rendered as a FIELD VALUE.
@@ -22,9 +25,10 @@ import { useLoticsLocale } from "./locale";
22
25
  * already mean by it — they open a popover too.)
23
26
  *
24
27
  * 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)
28
+ * press the value → the facts, in a popover (free, so it's the whole surface)
29
+ * Change / Clear → the LINK: point it elsewhere, or leave it empty
30
+ * Edit → the RECORD's own facts, as a draft, committed by Save
31
+ * Open → the referenced record's page (optional; the departure)
28
32
  *
29
33
  * Open used to sit ON the field as an `InlineButton`, justified as "the common
30
34
  * act". It was neither common nor an act about the value. `actions` is for verbs
@@ -38,39 +42,175 @@ import { useLoticsLocale } from "./locale";
38
42
  * is the whole box again and the focus ring rings the field, not a narrower
39
43
  * region inside it.
40
44
  *
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.
45
+ * The link verbs earn their depth twice over. They ANSWER the question the peek
46
+ * asks — "is this the right one?" — so the check and the correction are one
47
+ * gesture; and a detach control repeated down a column of references is noise in
48
+ * the scan path for an act most readers never perform. Depth is not a hiding
49
+ * place here: pressing a field-shaped value is the first thing anyone tries, and
50
+ * the verbs are plainly visible once open.
51
+ *
52
+ * Both link verbs are named for the INTENT, not the mechanism, and neither is
53
+ * `danger`: unsetting a reference is recoverable, and calling it "Remove" made it
54
+ * wear the colour reserved for destruction. `Clear` is the plain detach; `Change`
55
+ * is the same write followed by the picker, focused, because the press said
56
+ * "wrong one" and the correction should cost one gesture rather than three. Both
57
+ * are STANDARD — the footer has one shape everywhere, and a required reference
58
+ * gains nothing from hiding Clear, since Change-then-decline reaches the same
59
+ * empty state anyway.
47
60
  *
48
61
  * What it encodes is the PEEK CONTRACT — the marker, the popover's grammar, and
49
62
  * where each verb sits — not a layout convenience. It was a template composition
50
63
  * until a second surface needed it (a register's workspace drawer lists the same
51
64
  * references), which is the bar for lifting one here.
52
65
  */
66
+ export interface ReferenceFact {
67
+ label: string;
68
+ value: string;
69
+ /**
70
+ * The key this fact SAVES under. Its presence is what makes the fact
71
+ * editable — a fact without one stays read-only in the draft, which is how a
72
+ * derived value (a total, a status, a formatted id) rides along in the peek
73
+ * without pretending to be typeable.
74
+ */
75
+ name?: string;
76
+ /** A value that runs past one line — an address, a bank account block. */
77
+ multiline?: boolean;
78
+ /**
79
+ * What the draft edits this fact WITH. `text` (default) is a text input;
80
+ * `date` is the kit's `DatePicker`, and the value is its ISO string.
81
+ *
82
+ * A date is not a short string that happens to look like one. Left as text it
83
+ * takes any shape the reader types, and the field it saves into accepts one —
84
+ * so the format ambiguity is only discovered by whatever reads the record
85
+ * next, which is exactly the kind of wrong a draft is supposed to catch at
86
+ * the input.
87
+ */
88
+ type?: "text" | "date";
89
+ }
90
+
53
91
  export interface ReferenceFieldProps {
54
92
  name: string;
55
93
  code?: string;
56
- facts: { label: string; value: string }[];
94
+ facts: ReferenceFact[];
57
95
  /** Announced name of the peek trigger, e.g. "Harbor Freight Lines — details". */
58
96
  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;
97
+ /**
98
+ * The referenced record's own page. OPTIONAL, because a reference that can be
99
+ * corrected in place has no errand to send the reader on and a verb wired to
100
+ * nothing is worse than an absent one (it promises a destination, then spends
101
+ * the press on nothing). Omit it and the peek simply carries no Open.
102
+ */
103
+ onOpen?: () => void;
104
+ openLabel?: string;
105
+ /**
106
+ * Point this field at a DIFFERENT record. REQUIRED.
107
+ *
108
+ * Unsets the reference so the call site's picker returns, and the caller
109
+ * should hand that picker focus: the press said "wrong one", so the next move
110
+ * is choosing the right one and the correction should cost one gesture.
111
+ */
112
+ onChange: () => void;
113
+ /**
114
+ * Leave the reference EMPTY — the plain detach. REQUIRED.
115
+ *
116
+ * The SAME write as `onChange`; they differ only in FOLLOW-THROUGH, and
117
+ * nothing focuses after this one — auto-opening a picker would argue with the
118
+ * intent the press just stated. They are separate props because that
119
+ * follow-through is not inferable from the write: nothing downstream can tell
120
+ * "unset it" from "unset it, I'm about to pick another", and guessing wrong
121
+ * either steals the caret from someone who wanted an empty field or sends
122
+ * someone who wanted a swap hunting for the input.
123
+ *
124
+ * BOTH are required, including on a reference the record cannot do without.
125
+ * Hiding Clear there enforces nothing — Change, then decline to pick, lands on
126
+ * the same empty state — so it only removes the direct route to a place the
127
+ * reader can already reach, and makes the footer's shape vary for no gain.
128
+ * Whether empty is VALID is the row's business (its `warning`, its
129
+ * validation), never the peek's.
130
+ */
131
+ onClear: () => void;
132
+ /**
133
+ * Commit the draft. Receives ONLY the facts whose value CHANGED, keyed by
134
+ * `name` — never a full snapshot, so a lock or a `before_update` hook sees the
135
+ * edit the reader actually made. Present (with at least one named fact) is
136
+ * what gives the peek its Edit verb.
137
+ */
138
+ onSave?: (patch: Record<string, string>) => void | Promise<void>;
63
139
  }
64
140
 
65
141
  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
142
+ const { name, code, facts, accessibilityLabel, onOpen, openLabel, onChange, onClear, onSave } = props;
143
+ // The peek's verbs are the component's OWN chrome, so they come from the
68
144
  // pack — hardcoding them shipped "Open"/"Remove" into every localized app.
69
145
  const t = useLoticsLocale().referenceField;
146
+ // The tag a date control renders in — `vi` yields dd/MM/yyyy with no per-
147
+ // instance prop, which is the same resolution `DatePicker` does internally.
148
+ const localeTag = useLocaleTag();
70
149
  const anchor = useRef<View>(null);
71
150
  const [peekOpen, setPeekOpen] = useState(false);
151
+ const [draft, setDraft] = useState<Record<string, string> | null>(null);
152
+ const [saving, setSaving] = useState(false);
153
+ const [error, setError] = useState<string | null>(null);
154
+ const editing = draft !== null;
155
+ const editable = onSave != null && facts.some((f) => f.name != null);
156
+
157
+ const openDraft = () => {
158
+ const seed: Record<string, string> = {};
159
+ for (const f of facts) if (f.name != null) seed[f.name] = f.value;
160
+ setError(null);
161
+ setDraft(seed);
162
+ };
163
+ const closeDraft = () => {
164
+ setDraft(null);
165
+ setError(null);
166
+ };
167
+ // ONLY what changed. A full snapshot would clobber a concurrent edit to a
168
+ // fact this reader never touched, and would fire every `before_update` hook
169
+ // on the record rather than the ones the edit actually earned.
170
+ const changed = (d: Record<string, string>): Record<string, string> => {
171
+ const patch: Record<string, string> = {};
172
+ for (const f of facts) if (f.name != null && d[f.name] !== f.value) patch[f.name] = d[f.name] ?? "";
173
+ return patch;
174
+ };
175
+ const dirty = editing && Object.keys(changed(draft)).length > 0;
176
+
177
+ const save = async () => {
178
+ if (draft == null || onSave == null) return;
179
+ setSaving(true);
180
+ setError(null);
181
+ try {
182
+ await onSave(changed(draft));
183
+ setDraft(null);
184
+ } catch (e) {
185
+ // The draft SURVIVES a failed save — discarding what someone just typed
186
+ // because the network refused it is the one unforgivable thing a form
187
+ // does. They retry, or they cancel; the component never decides.
188
+ setError(e instanceof Error ? e.message : String(e));
189
+ } finally {
190
+ setSaving(false);
191
+ }
192
+ };
193
+
72
194
  return (
73
- <Popover open={peekOpen} onOpenChange={setPeekOpen} triggerRef={anchor} side="bottom" align="start">
195
+ <Popover
196
+ open={peekOpen}
197
+ /* A DRAFT PINS THE POPOVER OPEN. Outside-press and Escape are this
198
+ surface's dismissal, and dismissal is silent — so while a draft is
199
+ open they would throw away typing with no undo and no trace. This is
200
+ the whole reason a peek could not hold self-persisting inline editors
201
+ (blur and dismiss are one gesture there); a draft with an explicit
202
+ Save has no such collision, PROVIDED the draft outranks the dismissal.
203
+ Cancel and Save are then the only ways out, which is what makes them
204
+ worth rendering. */
205
+ onOpenChange={(next) => {
206
+ if (!next && editing) return;
207
+ setPeekOpen(next);
208
+ if (!next) closeDraft();
209
+ }}
210
+ triggerRef={anchor}
211
+ side="bottom"
212
+ align="start"
213
+ >
74
214
  {/* THE KIT'S FIELD, not a lookalike. This was hand-rolled — a Pressable
75
215
  wearing copies of the field's border, radius, height and hover — and it
76
216
  promptly fell out of step the moment the kit's field changed: it kept a
@@ -120,57 +260,149 @@ export function ReferenceField(props: ReferenceFieldProps) {
120
260
  <PopoverContent style={{ width: 320 }} disableBodyScroll>
121
261
  <View style={{ gap: 12 }}>
122
262
  <DialogSectionHeadingTitle description={code}>{name}</DialogSectionHeadingTitle>
123
- {/* PLAIN `Text`, not `InlineStatic`and `minHeight` 28, `DetailRow`'s
124
- own default, instead of the `DetailTable`'s 40.
263
+ {/* ONE geometry for both modes the table's own 40px band, which is
264
+ `CONTROL_HEIGHT` and exactly what `TextInputField` renders at. The
265
+ read row therefore RESERVES the space its editor will need, and
266
+ pressing Edit changes the value cell's species without moving a
267
+ single label.
125
268
 
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
- ))}
269
+ This read at 28 for a while, on the argument that the grid had no
270
+ editors and a glance surface should not buy a control band for a
271
+ 20px value. That premise expired the moment the peek gained a
272
+ draft, and the constant outlived it: 28 40 grew every row by 12px
273
+ and re-centred every label by 6px inside it, so by the third fact
274
+ the reader's eye had moved 24px to follow text it was already
275
+ reading. A view↔edit toggle reserves the input's height; the 36px
276
+ of extra air across three facts is what that costs, and it is
277
+ cheaper than making someone re-find their place on every press. */}
278
+ <DetailTable labelWidth={88}>
279
+ {facts.map((f) =>
280
+ editing && f.name != null ? (
281
+ <DetailRow key={f.label} label={f.label}>
282
+ {f.type === "date" ? (
283
+ <DatePicker
284
+ value={draft[f.name] || null}
285
+ onValueChange={(v) => setDraft({ ...draft, [f.name as string]: v })}
286
+ disabled={saving}
287
+ />
288
+ ) : (
289
+ <TextInputField
290
+ value={draft[f.name] ?? ""}
291
+ onChangeText={(v) => setDraft({ ...draft, [f.name as string]: v })}
292
+ multiline={f.multiline}
293
+ autoGrow={f.multiline}
294
+ disabled={saving}
295
+ accessibilityLabel={f.label}
296
+ />
297
+ )}
298
+ </DetailRow>
299
+ ) : (
300
+ <DetailRow key={f.label} label={f.label}>
301
+ {/* `InlineStatic`, NOT a bare `Text`. An input's words start at
302
+ `CONTROL_TEXT_INSET` — 1px border plus 8px padding — and a
303
+ raw Text starts at 0, so pressing Edit slid every value 9px
304
+ right. This is the component built to sit flush beside
305
+ editors, border and all, and reaching for it means the
306
+ alignment survives the control geometry changing. Copying
307
+ the box here instead would drift the first time it does. */}
308
+ <InlineStatic
309
+ /* A date's canonical value is its ISO string — that is what
310
+ the picker reads and what Save sends — so the FORMATTING
311
+ happens here, where the type is known. Handing the caller
312
+ that job would make `value` mean two things (display in
313
+ read, ISO in the draft) and the two would drift. */
314
+ value={f.type === "date" ? formatDate(f.value, { locale: localeTag }) : f.value}
315
+ multiline={f.multiline}
316
+ />
317
+ </DetailRow>
318
+ ),
319
+ )}
141
320
  </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. */}
321
+ {error ? <Text size="sm" color="danger">{error}</Text> : null}
322
+ {/* THE FOOTER — every verb is a `Button`, at ONE altitude. The peek is
323
+ dialog-scale (its title already takes the dialog rung), and a
324
+ dialog's verbs are buttons; mixing weights here made the reader
325
+ rank three acts that are simply three acts.
326
+
327
+ CHANGE, not Remove, and not `danger`. The mechanism is a detach,
328
+ but nobody arrives at it wanting an empty field — they arrive
329
+ having read the facts and decided this is the wrong record, and
330
+ what follows is the picker. Naming it for the mechanism made a
331
+ recoverable correction wear the colour reserved for destruction.
332
+ It is the answer to the question the peek asks — "is this the right
333
+ one?" — so it sits FIRST, at the left, where that answer belongs.
334
+
335
+ OPEN stays right and OPTIONAL: it is the departure, and on a peek
336
+ whose facts can be corrected in place, leaving is the rarest move.
337
+
338
+ The whole footer is conditional — with all three verbs optional, an
339
+ edit-only reference would otherwise draw a rule under the facts and
340
+ fence off an empty band, which is chrome asserting a structure that
341
+ is not there. */}
149
342
  <Divider />
343
+ {/* THE DRAFT'S FOOTER REPLACES the peek's, it does not join it. Change
344
+ and Open are moves AWAY from an unsaved draft — one detaches the
345
+ record being edited, one navigates off it — so offering either here
346
+ would be offering to lose the typing. Cancel and Save are the only
347
+ two exits, which is also what the pinned popover promised. */}
348
+ {editing ? (
349
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
350
+ <View style={{ flex: 1 }} />
351
+ <Button title={t.cancel} color="secondary" disabled={saving} onPress={closeDraft} />
352
+ {/* Disabled until something DIFFERS: with nothing to send, a save
353
+ is a write that fires the record's hooks and bumps its
354
+ modified stamp to record that nobody changed anything. */}
355
+ <Button
356
+ title={saving ? t.saving : t.save}
357
+ color="primary"
358
+ disabled={!dirty || saving}
359
+ onPress={() => void save()}
360
+ />
361
+ </View>
362
+ ) : (
150
363
  <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}
364
+ {/* LEFT PAIR acts on the LINK — which record this points at. Both
365
+ are unconditional: the peek always offers "point it elsewhere"
366
+ and "leave it empty", so its footer has ONE shape everywhere
367
+ instead of four depending on which callbacks a call site
368
+ remembered. */}
369
+ <Button
370
+ title={t.change}
371
+ color="secondary"
372
+ accessibilityLabel={`${t.change} ${name}`}
373
+ onPress={() => { setPeekOpen(false); onChange(); }}
374
+ />
375
+ {/* No fill Clear is the least-reached verb here, and the one whose
376
+ result the reader is least likely to want by accident, so it
377
+ carries the least weight of the four. */}
378
+ <Button
379
+ title={t.clear}
380
+ accessibilityLabel={`${t.clear} ${name}`}
381
+ onPress={() => { setPeekOpen(false); onClear(); }}
382
+ />
383
+ {/* The spacer is the SEAM between what the two pairs touch: left the
384
+ link, right the record it points at. Without that split, Open's
385
+ position is just "pushed over". */}
168
386
  <View style={{ flex: 1 }} />
387
+ {/* RIGHT PAIR acts on the RECORD the link points at — correct its
388
+ data, or go to it. Edit takes the ONE filled-dark rung because it
389
+ is the only verb here that leads to a commit, and it hands that
390
+ rung straight to Save when the draft opens: one primary per mode,
391
+ never two. Every other verb stays `secondary` and FILLED — a
392
+ fill-less Button shows no box, so its ink sits a padding inside
393
+ its own edge, and one boxless label in a row of boxes reads as
394
+ indented (the geometry that made `TextButton` exist). */}
395
+ {editable ? (
396
+ <Button title={t.edit} color="primary" accessibilityLabel={`${t.edit} — ${name}`} onPress={openDraft} />
397
+ ) : null}
169
398
  {/* `openLabel` names the DESTINATION ("Open customer"): a page carries
170
399
  four of these peeks, and four buttons announcing a bare "Open"
171
400
  are four controls a screen reader cannot tell apart. */}
172
- <Button title={t.open} color="secondary" accessibilityLabel={openLabel} onPress={() => { setPeekOpen(false); onOpen(); }} />
401
+ {onOpen ? (
402
+ <Button title={t.open} color="secondary" accessibilityLabel={openLabel} onPress={() => { setPeekOpen(false); onOpen(); }} />
403
+ ) : null}
173
404
  </View>
405
+ )}
174
406
  </View>
175
407
  </PopoverContent>
176
408
  </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,