@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 +10 -3
- package/MIGRATION.md +38 -0
- package/docs/catalog.md +46 -6
- package/docs/composition.md +24 -12
- package/examples/tpl_record.tsx +113 -23
- package/package.json +1 -1
- package/src/inline_static.tsx +18 -2
- package/src/locale.tsx +5 -4
- package/src/reference_field.tsx +260 -60
- package/src/text_input_field.tsx +16 -3
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
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
package/docs/composition.md
CHANGED
|
@@ -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
|
-
**
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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 link — Change (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
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
743
|
+
onChange={() => { setChanging(true); onUnset(); }}
|
|
744
|
+
onClear={() => { setChanging(false); onUnset(); }}
|
|
745
|
+
onSave={(patch) => onSaveFacts(rec.id, patch)}
|
|
731
746
|
/>
|
|
732
747
|
) : (
|
|
733
|
-
<Combobox
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1672
|
-
|
|
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
|
-
|
|
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={() => {}}
|
|
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={() => {}}
|
|
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={() => {}}
|
|
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
package/src/inline_static.tsx
CHANGED
|
@@ -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
|
|
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;
|
|
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",
|
|
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ở",
|
|
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" },
|
package/src/reference_field.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
42
|
-
* asks — "is this the right one?" — so the check and the correction are
|
|
43
|
-
* gesture; and a detach
|
|
44
|
-
* scan path for an act most readers never perform. Depth is not a hiding
|
|
45
|
-
* here: pressing a field-shaped value is the first thing anyone tries, and
|
|
46
|
-
*
|
|
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:
|
|
81
|
+
facts: ReferenceFact[];
|
|
57
82
|
/** Announced name of the peek trigger, e.g. "Harbor Freight Lines — details". */
|
|
58
83
|
accessibilityLabel: string;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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,
|
|
67
|
-
// The peek's
|
|
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
|
|
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
|
-
{/*
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
<DetailTable labelWidth={88}
|
|
136
|
-
{facts.map((f) =>
|
|
137
|
-
|
|
138
|
-
<
|
|
139
|
-
|
|
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
|
-
{
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
{
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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>
|
package/src/text_input_field.tsx
CHANGED
|
@@ -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 +
|
|
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:
|
|
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:
|
|
250
|
+
paddingVertical: INPUT_PADDING_Y,
|
|
238
251
|
paddingHorizontal: 8,
|
|
239
252
|
fontFamily: fontFamilyRegular,
|
|
240
253
|
letterSpacing: -0.4,
|