@lotics/ui 27.12.0 → 27.13.1

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/docs/catalog.md CHANGED
@@ -755,7 +755,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
755
755
  just text. **`InlineSelect` is single OR multi** — pass `multi` for a tag SET (`value: T[]`,
756
756
  commits the new set on popover-CLOSE; selected tags render as badges via `renderSelected`),
757
757
  mirroring `Select`'s `multi` axis; there is NO separate tag component. Both modes take
758
- `allowCustom` (a create-a-tag/option row) + `searchable`, plus **`autoFocus`** to open the list
758
+ `allowCustom` (a create-a-tag/option row) + `searchable` + **`customOptionPlacement`** (default
759
+ `"bottom"`, right for a tag field; pass `"top"` for a find-or-create REFERENCE picker over a long
760
+ registry, so the create row stays visible while the keyboard highlight stays on the first MATCH —
761
+ Enter on a partial query then attaches instead of duplicating), plus **`autoFocus`** to open the list
759
762
  on mount — for the picker a `ReferenceField`'s `Change` just dropped the reader into, so the
760
763
  correction stays one gesture; leave it off for a field that is merely empty, since stealing the
761
764
  list open on load is a different act. **`InlineMemberSelect` takes `avatarOnly`**
@@ -494,7 +494,7 @@ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping
494
494
  (colored `Text`), not the buttons.
495
495
  - **Create buttons carry NO `+` icon** — "Add fee", not "+ Add fee". A create is `primary` and
496
496
  rides its section's HEADING row at the right edge — see § The add-placement law for why, and
497
- for the one-verb-one-weight rule that keeps it primary in the `EmptyState` too. (The
497
+ for the one-verb-one-weight rule that keeps it `primary` wherever it renders. (The
498
498
  icon-only `+` `IconButton` — a composer's attach — is a different thing and keeps its glyph.)
499
499
 
500
500
  ## The action-gating law — disabled + the reason as a co-located Callout
@@ -600,11 +600,13 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
600
600
 
601
601
  The ADD is the one verb this law does NOT place next to its effect, and the add-placement law
602
602
  says why: a position that never moves beats a position that is adjacent, because "below the
603
- last row" is a different place on every list and no place at all on an empty one.
604
- **An add is `primary`, in the `EmptyState` too.** It is the act its section offers, so it
605
- carries that section's weight; a lone `secondary` button reads as though the real action were
606
- somewhere else. The rule that matters is that ONE VERB KEEPS ONE WEIGHT the same add must
607
- not be primary on an empty list and secondary on a full one.
603
+ last row" is a different place on every list and no place at all on an empty one. The heading
604
+ row therefore carries identity + meta + view controls + the section's ADD, and no other content
605
+ verb. **An add is `primary` wherever it renders** on that heading row, or inside an
606
+ `EmptyState` in the one case § Empty states allows, where there is no heading to carry it. It
607
+ is the act its section offers, so it carries that section's weight; a lone `secondary` button
608
+ reads as though the real action were somewhere else. The rule that matters is that ONE VERB
609
+ KEEPS ONE WEIGHT — the same add must not be primary on an empty list and secondary on a full one.
608
610
  A record surface therefore carries several primaries, one per section, and that is correct:
609
611
  "one primary per SCREEN" governs the page BAND, not a stack of independent sections each with
610
612
  its own single act. The cost is real and worth knowing — the more sections offer an act, the
@@ -683,7 +685,13 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
683
685
  line** that carries the type + identifying fact as TEXT (`type, code, city`) — COMMA-joined, never
684
686
  a middot (see Microcopy), and free to wrap the row taller. A type/attribute is **NOT a status, so it is text, never a `Badge`** (see
685
687
  Badge discipline below). (The kit suppresses `renderOptionContent` on the `allowCustom` "Create …" row so it keeps its plain + look.)
686
- Plus `allowCustom` + `customOptionPlacement:"top"` + `customOptionLabel={q => 'Create "…"'}`. In
688
+ Plus `allowCustom` + `customOptionPlacement:"top"` + `customOptionLabel={q => 'Create "…"'}`
689
+ the same three on `InlineSelect` where the reference sits in an inline-editor grid rather than a
690
+ form. **The create row names the ENTITY, not the role** (`Create new customer "…"` on a carrier
691
+ row, when one registry serves all the party roles): a per-role noun claims a registry per role,
692
+ which is a data model the screen does not have. The role is named by the PLACEHOLDER, and derive
693
+ that placeholder from the same noun the row is labelled by — passing the two separately is how a
694
+ row ends up promising "Find or create" over a picker that can only find. In
687
695
  `onValueChange`, an existing pick attaches; the custom row opens a create **`Dialog` prefilled with
688
696
  the typed query** (mint the record + link it) — so a party absent from the registry is created
689
697
  WITHOUT leaving the record. `customOptionPlacement:"top"` keeps the create row visible while the
package/docs/templates.md CHANGED
@@ -53,6 +53,22 @@ the package index is [../AGENTS.md](../AGENTS.md).
53
53
  | AI ranks answers — look-up-and-explain | `tpl_lookup` |
54
54
  | AI over a record's documents (extract / cross-check / generate) | `tpl_record` — its Documents + Document set sections |
55
55
 
56
+ ### Register + drawer, or a navigated page?
57
+
58
+ The register is the DEFAULT, and its row press opens the workspace `Drawer`. A record earns
59
+ its own navigated route only when one of these holds:
60
+
61
+ - **It is a destination.** Someone arrives cold from outside the register — a link in an
62
+ email, a notification, a handoff from another desk — and must land on the record without
63
+ the list to open it from.
64
+ - **The work needs the canvas.** A docked comments gutter, a document read beside the fields,
65
+ an outline rail worth navigating — width the drawer's single column cannot give.
66
+
67
+ The volume of DATA is not the test. A record with forty fields still closes in the drawer if
68
+ the operator opens it from the list, works it, and goes back to the list. What an app without
69
+ a destination skips is the ROUTE, never the template: the drawer renders `tpl_record` either
70
+ way, so "no record page" must never become a hand-built detail surface.
71
+
56
72
  ## Composition rules — how a record surface is assembled
57
73
 
58
74
  Laws for any screen where a record's data is edited and a primary action produces its output
@@ -268,7 +284,10 @@ billing, and quick-capture templates. Top → bottom:
268
284
  ATTENTION state as a co-located `Callout` (e.g. overdue — record-scoped state reads here;
269
285
  field-scoped state stays on its field), then a quiet `SummaryLine` (desk, documents
270
286
  , to collect).
271
- - **GENERAL — the MAIN details, FIRST in the rail**; every later section is supplementary.
287
+ - **GENERAL — the MAIN details, first in THIS record's rail** because its work starts at the
288
+ details; rule 1 orders sections by work flow, so a surface whose work starts at a DOCUMENT
289
+ (an invoice desk, a claims desk) leads with Documents and General follows. Every later
290
+ section is supplementary.
272
291
  A `SubsectionStack` (space-only beat) of a headingless LEAD group (the key facts) plus
273
292
  named groups (e.g. Order / Fulfilment / System — system identifiers get a HOME, not a
274
293
  per-row badge). Every group's `DetailTable` repeats the same `labelWidth` so the grid
@@ -779,14 +779,53 @@ function RouteStops({ stops, onChange }: { stops: Stop[]; onChange: (next: Stop[
779
779
  );
780
780
  }
781
781
 
782
+ /** The reference picker's option row, for EVERY picker over the customer registry
783
+ * — the Customer row and the three party rows. A glyph tile beside the name over
784
+ * a COMMA-joined muted metadata line (the identifying facts as TEXT: a type or
785
+ * attribute is not a status, so it is never a `Badge`).
786
+ *
787
+ * It is shared rather than written per row precisely because four pickers over
788
+ * ONE registry drifting into four looks is how this surface decayed the first
789
+ * time: three of them rendered a wall of bare names while the fourth carried the
790
+ * city and tax ID a reader needs to tell two similarly-named companies apart. */
791
+ function customerOptionRow(o: PickerOption<string, Customer>) {
792
+ return (
793
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
794
+ <View style={{ width: 28, height: 28, borderRadius: 7, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
795
+ <Icon name="building-2" size={14} color={colors.zinc[600]} />
796
+ </View>
797
+ <View style={{ flex: 1, minWidth: 0 }}>
798
+ <Text size="sm" numberOfLines={1}>{o.label}</Text>
799
+ {o.data ? <Text size="xs" color="muted">{[o.data.city, o.data.taxId ? `Tax ID ${o.data.taxId}` : "No tax ID"].filter(Boolean).join(", ")}</Text> : null}
800
+ </View>
801
+ </View>
802
+ );
803
+ }
804
+
805
+ /** The create row names the ENTITY, never the role the field fills it for: one
806
+ * registry holds all four, so "Create new customer" on the carrier row says the
807
+ * true thing — a customer record, attached here as the carrier. A per-role noun
808
+ * would imply four registries and four kinds of record, which is the data model
809
+ * the screen deliberately does not have. The ROLE is named by the field's own
810
+ * placeholder, where it belongs. */
811
+ const createCustomerLabel = (query: string) => `Create new customer “${query}”`;
812
+ const NO_CUSTOMER_MATCH = "No customer matches — type a name to create one";
813
+
814
+ /** Which party row a pick or a create is destined for. */
815
+ type PartyTarget = "customer" | "shipTo" | "notify" | "carrier";
816
+
782
817
  /** A party ROW — a reference when one is attached, the find-or-create picker when
783
818
  * not. The two states share the row so the field never moves; the picker is the
784
819
  * field's EMPTY state, not a different kind of surface. */
785
- function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onUnset, onSaveFacts }: {
820
+ function PartyRow({ role, noun, rec, options, onPick, onOpen, onUnset, onSaveFacts }: {
786
821
  role: string;
822
+ /** The ROLE this field fills, for its placeholder — "consignee", "carrier".
823
+ * The placeholder is derived from it rather than passed alongside it: the two
824
+ * were separate props once, and the row ended up promising "Find or create"
825
+ * over a picker that could only find. */
826
+ noun: string;
787
827
  rec: Customer | null;
788
828
  options: PickerOption<string, Customer>[];
789
- placeholder: string;
790
829
  onPick: (opt: PickerOption<string, Customer>) => void;
791
830
  onOpen: () => void;
792
831
  onUnset: () => void;
@@ -825,10 +864,14 @@ function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onUnset, on
825
864
  options={options}
826
865
  autoFocus={changing}
827
866
  onValueChange={(o) => { setChanging(false); onPick(o); }}
867
+ renderOptionContent={customerOptionRow}
828
868
  reflectSelection={false}
869
+ allowCustom
870
+ customOptionPlacement="top"
871
+ customOptionLabel={createCustomerLabel}
829
872
  >
830
- <ComboboxInput icon="search" placeholder={placeholder} accessibilityLabel={role} />
831
- <ComboboxContent emptyText="No match" />
873
+ <ComboboxInput icon="search" placeholder={`Find or create a ${noun}`} accessibilityLabel={role} />
874
+ <ComboboxContent emptyText={NO_CUSTOMER_MATCH} />
832
875
  </Combobox>
833
876
  )}
834
877
  </DetailRow>
@@ -987,7 +1030,13 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
987
1030
  // ── customer section — TWO states: attached (read-only card, Remove
988
1031
  // detaches) or empty (the find-or-create search). The "create" branch opens
989
1032
  // a focused Dialog; non-null draft = the dialog is open.
990
- const [custDraft, setCustDraft] = useState<{ name: string; taxId: string; contact: string; city: string } | null>(null);
1033
+ //
1034
+ // `target` is the row that opened it. All four party rows attach a record from
1035
+ // the SAME registry, so they share one dialog rather than each carrying a copy
1036
+ // — and the draft names its destination as DATA instead of the dialog holding a
1037
+ // callback, so what is in flight stays inspectable and the commit stays one
1038
+ // function.
1039
+ const [custDraft, setCustDraft] = useState<{ target: PartyTarget; name: string; taxId: string; contact: string; city: string } | null>(null);
991
1040
  // The Fetch button's in-flight state (the registry lookup).
992
1041
  const [fetching, setFetching] = useState(false);
993
1042
 
@@ -1303,14 +1352,24 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1303
1352
  data: c,
1304
1353
  }));
1305
1354
 
1306
- // Find-or-create resolution: an existing pick attaches; the custom row opens
1307
- // the create Dialog, prefilled with the query.
1308
- const onPickCustomer = (opt: PickerOption<string, Customer>) => {
1355
+ // Where each party row's attach lands. One map, so a row added later cannot
1356
+ // acquire a create path and quietly forget to wire its attach.
1357
+ const attachParty: Record<PartyTarget, (id: string | null) => void> = {
1358
+ customer: setCustomerId,
1359
+ shipTo: setShipTo,
1360
+ notify: setNotify,
1361
+ carrier: setCarrier,
1362
+ };
1363
+
1364
+ // Find-or-create resolution, shared by all four party rows: an existing pick
1365
+ // attaches; the custom row opens the create Dialog, prefilled with the query
1366
+ // and carrying the row that asked for it.
1367
+ const onPickParty = (target: PartyTarget) => (opt: PickerOption<string, Customer>) => {
1309
1368
  if (customers.some((c) => c.id === opt.value)) {
1310
- setCustomerId(opt.value);
1369
+ attachParty[target](opt.value);
1311
1370
  return;
1312
1371
  }
1313
- setCustDraft({ name: opt.value, taxId: "", contact: "", city: "" });
1372
+ setCustDraft({ target, name: opt.value, taxId: "", contact: "", city: "" });
1314
1373
  };
1315
1374
 
1316
1375
  const taxIdError =
@@ -1331,7 +1390,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1331
1390
  since: "",
1332
1391
  };
1333
1392
  setCustomers((prev) => [...prev, c]);
1334
- setCustomerId(c.id);
1393
+ attachParty[custDraft.target](c.id);
1335
1394
  setCustDraft(null);
1336
1395
  };
1337
1396
 
@@ -1401,10 +1460,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1401
1460
  Alert.alert("Receipt", `Printing receipt for ${formatMoney(grandTotal)}.`, [{ text: "OK" }]);
1402
1461
  };
1403
1462
 
1404
- // Where the record SITS — the one index the pipeline reads off, and the act
1405
- // that leaves that desk. A closed record is past the last desk, so no stage
1406
- // is current and no gate is offered.
1407
- const deskIndex = stage === "closed" ? DESKS.length : DESKS.findIndex((x) => x.key === stage);
1408
1463
  // `handoff` separates the two acts that LOOK alike, as a DISCRIMINATED union
1409
1464
  // so the difference is in the type and not just a runtime branch: handing off
1410
1465
  // has a receiving DESK (it asks whom, then stamps that desk), closing has no
@@ -1871,25 +1926,15 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1871
1926
  drops you into is already focused and open, and the
1872
1927
  correction stays one gesture. */
1873
1928
  autoFocus={changingCustomer}
1874
- onValueChange={(o) => { setChangingCustomer(false); onPickCustomer(o); }}
1875
- renderOptionContent={(o) => (
1876
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
1877
- <View style={{ width: 28, height: 28, borderRadius: 7, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
1878
- <Icon name="building-2" size={14} color={colors.zinc[600]} />
1879
- </View>
1880
- <View style={{ flex: 1, minWidth: 0 }}>
1881
- <Text size="sm" numberOfLines={1}>{o.label}</Text>
1882
- {o.data ? <Text size="xs" color="muted">{[o.data.city, o.data.taxId ? `Tax ID ${o.data.taxId}` : "No tax ID"].filter(Boolean).join(", ")}</Text> : null}
1883
- </View>
1884
- </View>
1885
- )}
1929
+ onValueChange={(o) => { setChangingCustomer(false); onPickParty("customer")(o); }}
1930
+ renderOptionContent={customerOptionRow}
1886
1931
  reflectSelection={false}
1887
1932
  allowCustom
1888
1933
  customOptionPlacement="top"
1889
- customOptionLabel={(q) => `Create new customer “${q}”`}
1934
+ customOptionLabel={createCustomerLabel}
1890
1935
  >
1891
1936
  <ComboboxInput icon="search" placeholder="Search customers by name…" accessibilityLabel="Attach customer" />
1892
- <ComboboxContent emptyText="No customer matches — type a name to create one" />
1937
+ <ComboboxContent emptyText={NO_CUSTOMER_MATCH} />
1893
1938
  </Combobox>
1894
1939
  )}
1895
1940
  </DetailRow>
@@ -2208,14 +2253,12 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2208
2253
  <SubsectionHeading><SubsectionHeadingTitle>Consignment &amp; terms</SubsectionHeadingTitle></SubsectionHeading>
2209
2254
  <DetailTable labelWidth={150} minHeight={40}>
2210
2255
  <PartyRow
2211
- role="Ship to" rec={shipToRec} options={customerOptions}
2212
- placeholder="Find or create a consignee"
2213
- onPick={(o) => setShipTo(o.value)} onOpen={() => {}} onUnset={() => setShipTo(null)} onSaveFacts={saveCustomerFacts}
2256
+ role="Ship to" noun="consignee" rec={shipToRec} options={customerOptions}
2257
+ onPick={onPickParty("shipTo")} onOpen={() => {}} onUnset={() => setShipTo(null)} onSaveFacts={saveCustomerFacts}
2214
2258
  />
2215
2259
  <PartyRow
2216
- role="Notify" rec={notifyRec} options={customerOptions}
2217
- placeholder="Find or create a notify party"
2218
- onPick={(o) => setNotify(o.value)} onOpen={() => {}} onUnset={() => setNotify(null)} onSaveFacts={saveCustomerFacts}
2260
+ role="Notify" noun="notify party" rec={notifyRec} options={customerOptions}
2261
+ onPick={onPickParty("notify")} onOpen={() => {}} onUnset={() => setNotify(null)} onSaveFacts={saveCustomerFacts}
2219
2262
  />
2220
2263
  {/* A CHOICE IS ONE ROW, like every other field.
2221
2264
  `RadioPicker` renders three full-width bordered choices with
@@ -2314,9 +2357,8 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
2314
2357
  ) : null}
2315
2358
  <DetailTable labelWidth={150} minHeight={40}>
2316
2359
  <PartyRow
2317
- role="Carrier" rec={carrierRec} options={customerOptions}
2318
- placeholder="Find or create a carrier"
2319
- onPick={(o) => setCarrier(o.value)} onOpen={() => {}} onUnset={() => setCarrier(null)} onSaveFacts={saveCustomerFacts}
2360
+ role="Carrier" noun="carrier" rec={carrierRec} options={customerOptions}
2361
+ onPick={onPickParty("carrier")} onOpen={() => {}} onUnset={() => setCarrier(null)} onSaveFacts={saveCustomerFacts}
2320
2362
  />
2321
2363
  <DetailRow label="Booking no.">
2322
2364
  <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": "27.12.0",
3
+ "version": "27.13.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -49,6 +49,12 @@ interface InlineSelectBaseProps<T extends string, D = unknown> {
49
49
  allowCustom?: boolean;
50
50
  /** Label for the create row (default: `Add "<query>"`). */
51
51
  customOptionLabel?: (query: string) => string | null;
52
+ /** Where the create row sits — default "bottom", which is right for a tag field
53
+ * whose list is short and whose create row is the ordinary outcome. Pass "top"
54
+ * for a find-or-create REFERENCE picker over a long registry: the create row
55
+ * stays visible without scrolling while the keyboard highlight stays on the
56
+ * first MATCH, so Enter on a partial query attaches rather than duplicating. */
57
+ customOptionPlacement?: "top" | "bottom";
52
58
  }
53
59
 
54
60
  /**
@@ -129,7 +135,7 @@ function InlineSelectShell(props: {
129
135
  }
130
136
 
131
137
  export function InlineSelect<T extends string, D = unknown>(props: InlineSelectProps<T, D>) {
132
- const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, variant, actions, autoFocus = false } = props;
138
+ const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, customOptionPlacement, variant, actions, autoFocus = false } = props;
133
139
  const labels = useLoticsLocale().inline;
134
140
  const [open, setOpen] = useState(autoFocus);
135
141
  const [saving, setSaving] = useState(false);
@@ -192,6 +198,7 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
192
198
  onRequestClose={() => onOpenChange(false)}
193
199
  allowCustom={allowCustom}
194
200
  customOptionLabel={customOptionLabel}
201
+ customOptionPlacement={customOptionPlacement}
195
202
  onCustomCommit={(raw) => {
196
203
  const created = raw.trim() as T;
197
204
  if (created && !draft.includes(created)) setDraft([...draft, created]);
@@ -238,6 +245,7 @@ export function InlineSelect<T extends string, D = unknown>(props: InlineSelectP
238
245
  onRequestClose={() => setOpen(false)}
239
246
  allowCustom={allowCustom}
240
247
  customOptionLabel={customOptionLabel}
248
+ customOptionPlacement={customOptionPlacement}
241
249
  onCustomCommit={(raw) => {
242
250
  const created = raw.trim() as T;
243
251
  if (created) pick(created);