@lotics/ui 27.11.0 → 27.13.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/docs/catalog.md +12 -2
- package/docs/composition.md +13 -9
- package/docs/templates.md +4 -4
- package/examples/tpl_record.tsx +80 -38
- package/package.json +1 -1
- package/src/control_surface.ts +8 -1
- package/src/dialog.tsx +10 -0
- package/src/inline_select.tsx +9 -1
- package/src/screen_router.tsx +23 -3
package/docs/catalog.md
CHANGED
|
@@ -382,7 +382,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
382
382
|
- **`control_surface`** — `CONTROL_HEIGHT` (40), `CONTROL_RADIUS` (10), `CONTROL_TEXT_INSET`
|
|
383
383
|
(9 — how far a control insets its OWN text: 1px border + 8px padding; anything that must line
|
|
384
384
|
up with a control's WORDS rather than its box carries it, and `TASK_TEXT_INSET` IS it),
|
|
385
|
-
`FOCUS_RING
|
|
385
|
+
`FOCUS_RING` (a 2px OUTSET ring, so a container that CLIPS — any `ScrollView` — has to
|
|
386
|
+
keep padding on an edge a focusable child sits against, or the ring shears off on the
|
|
387
|
+
one interaction it signals; the padding still comes off the 8-grid, the ring is only
|
|
388
|
+
the floor), `HOVER_BORDER`, `CONTROL_TRANSITION`, `chipSurfaceStyle` — the shared
|
|
386
389
|
control-surface tokens.
|
|
387
390
|
- **`fonts.css`** — the Inter sheet (400/500/600, served by absolute URL so it resolves on
|
|
388
391
|
every origin an app runs from); the app entry imports it ONCE or every `Text` falls back
|
|
@@ -752,7 +755,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
752
755
|
just text. **`InlineSelect` is single OR multi** — pass `multi` for a tag SET (`value: T[]`,
|
|
753
756
|
commits the new set on popover-CLOSE; selected tags render as badges via `renderSelected`),
|
|
754
757
|
mirroring `Select`'s `multi` axis; there is NO separate tag component. Both modes take
|
|
755
|
-
`allowCustom` (a create-a-tag/option row) + `searchable
|
|
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
|
|
756
762
|
on mount — for the picker a `ReferenceField`'s `Change` just dropped the reader into, so the
|
|
757
763
|
correction stays one gesture; leave it off for a field that is merely empty, since stealing the
|
|
758
764
|
list open on load is a different act. **`InlineMemberSelect` takes `avatarOnly`**
|
|
@@ -1178,6 +1184,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1178
1184
|
stack (a Drawer drilling into a LINKED record swaps its header to a BACK `IconButton`
|
|
1179
1185
|
while `canGoBack`). Key the router by record id so stepping between records resets the
|
|
1180
1186
|
stack. Worked example: [`tpl_item_list`](../examples/tpl_item_list.tsx) drawer.
|
|
1187
|
+
**`useScreenActive()` says whether the surrounding `Screen` is the one on top** —
|
|
1188
|
+
because "stays mounted" means its effects keep running, so anything a screen claims
|
|
1189
|
+
GLOBALLY (a keyboard shortcut) must stand down while it is buried, or the covered
|
|
1190
|
+
screen answers input aimed at the one in front of it. `true` outside any `Screen`.
|
|
1181
1191
|
- **`route_matching`** — pure `:param` route-pattern utilities — `routeMatches` /
|
|
1182
1192
|
`parseRouteParams` / `findBestPattern` (exact beats parameterized) / `shouldRouteMatch`;
|
|
1183
1193
|
the matching core under `ScreenRouter`/`Dialog`.
|
package/docs/composition.md
CHANGED
|
@@ -493,8 +493,8 @@ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping
|
|
|
493
493
|
"success"/green button. Decision UIs put positive/negative color on the STATUS (dot) and verdict
|
|
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
|
-
|
|
497
|
-
|
|
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
|
|
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
|
|
@@ -594,15 +594,13 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
594
594
|
|---|---|
|
|
595
595
|
| a VALUE (copy it, open what it points at) | ON the value — `InlineButton` in the editor's `actions` |
|
|
596
596
|
| a ROW (remove it, move it) | ON the row — an `ActionMenu`, or the row's own controls |
|
|
597
|
-
| the LIST (one more of them) |
|
|
597
|
+
| the LIST (one more of them) | its section's HEADING row, right edge, `primary` — see [the add-placement law](#the-add-placement-law--a-sections-add-rides-its-heading-row-right-edge) |
|
|
598
598
|
| the RECORD (issue, print, hand off) | its own action row at the section's END |
|
|
599
599
|
| the record's EXISTENCE | the `DangerZone`, last on the page |
|
|
600
600
|
|
|
601
|
-
The
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
creates appears at the BOTTOM, nowhere near the button that made it. Put it under the last row,
|
|
605
|
-
on the list's own left edge, and the button is where its result will be.
|
|
601
|
+
The ADD is the one verb this law does NOT place next to its effect, and the add-placement law
|
|
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.
|
|
606
604
|
**An add is `primary`, in the `EmptyState` too.** It is the act its section offers, so it
|
|
607
605
|
carries that section's weight; a lone `secondary` button reads as though the real action were
|
|
608
606
|
somewhere else. The rule that matters is that ONE VERB KEEPS ONE WEIGHT — the same add must
|
|
@@ -685,7 +683,13 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
685
683
|
line** that carries the type + identifying fact as TEXT (`type, code, city`) — COMMA-joined, never
|
|
686
684
|
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
|
|
687
685
|
Badge discipline below). (The kit suppresses `renderOptionContent` on the `allowCustom` "Create …" row so it keeps its plain + look.)
|
|
688
|
-
Plus `allowCustom` + `customOptionPlacement:"top"` + `customOptionLabel={q => 'Create "…"'}
|
|
686
|
+
Plus `allowCustom` + `customOptionPlacement:"top"` + `customOptionLabel={q => 'Create "…"'}` —
|
|
687
|
+
the same three on `InlineSelect` where the reference sits in an inline-editor grid rather than a
|
|
688
|
+
form. **The create row names the ENTITY, not the role** (`Create new customer "…"` on a carrier
|
|
689
|
+
row, when one registry serves all the party roles): a per-role noun claims a registry per role,
|
|
690
|
+
which is a data model the screen does not have. The role is named by the PLACEHOLDER, and derive
|
|
691
|
+
that placeholder from the same noun the row is labelled by — passing the two separately is how a
|
|
692
|
+
row ends up promising "Find or create" over a picker that can only find. In
|
|
689
693
|
`onValueChange`, an existing pick attaches; the custom row opens a create **`Dialog` prefilled with
|
|
690
694
|
the typed query** (mint the record + link it) — so a party absent from the registry is created
|
|
691
695
|
WITHOUT leaving the record. `customOptionPlacement:"top"` keeps the create row visible while the
|
package/docs/templates.md
CHANGED
|
@@ -92,11 +92,11 @@ Laws for any screen where a record's data is edited and a primary action produce
|
|
|
92
92
|
7. **Header = identity + provenance.** Id, the key identifier, created-at. No metrics or badges
|
|
93
93
|
duplicating data a section already owns.
|
|
94
94
|
8. **Action placement — a verb goes where its effect lands.** Value verbs on the value
|
|
95
|
-
(`InlineButton`), row verbs on the row, an ADD
|
|
96
|
-
|
|
95
|
+
(`InlineButton`), row verbs on the row, an ADD on its section's HEADING row at the right edge
|
|
96
|
+
(primary — the one spot that does not move with the row count, and primary in the
|
|
97
|
+
`EmptyState` too: one verb, one weight),
|
|
97
98
|
a committing act in its own row at the section's end,
|
|
98
|
-
deletion in the `DangerZone`.
|
|
99
|
-
in the heading sits above what it extends and competes with the title. A committing act rides
|
|
99
|
+
deletion in the `DangerZone`. A committing act rides
|
|
100
100
|
an empty-label `DetailRow` so it lands on the CONTROL column — never right-floated, which
|
|
101
101
|
aligns to nothing (only overlay footers right-align).
|
|
102
102
|
9. **Destructive placement.** Solid `danger`, bottom-left after the entity's fields, ONE
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -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,
|
|
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={
|
|
831
|
-
<ComboboxContent emptyText=
|
|
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
|
-
|
|
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
|
-
//
|
|
1307
|
-
//
|
|
1308
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
1875
|
-
renderOptionContent={
|
|
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={
|
|
1934
|
+
customOptionLabel={createCustomerLabel}
|
|
1890
1935
|
>
|
|
1891
1936
|
<ComboboxInput icon="search" placeholder="Search customers by name…" accessibilityLabel="Attach customer" />
|
|
1892
|
-
<ComboboxContent emptyText=
|
|
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 & terms</SubsectionHeadingTitle></SubsectionHeading>
|
|
2209
2254
|
<DetailTable labelWidth={150} minHeight={40}>
|
|
2210
2255
|
<PartyRow
|
|
2211
|
-
role="Ship to" rec={shipToRec} options={customerOptions}
|
|
2212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/control_surface.ts
CHANGED
|
@@ -23,6 +23,13 @@ export const MIN_CONTROL_WIDTH = 160;
|
|
|
23
23
|
* (avatars, dots, switches, sliders, progress, icon buttons) stay full. */
|
|
24
24
|
export const CONTROL_RADIUS = 10;
|
|
25
25
|
|
|
26
|
+
/** How far {@link FOCUS_RING} extends BEYOND the control's border box. Module-local
|
|
27
|
+
* on purpose: it is a FLOOR for anyone who clips (a container reserving less than
|
|
28
|
+
* this on an edge a focusable child sits against shears that child's ring), never a
|
|
29
|
+
* spacing value — padding comes off the 8-grid in `spacing.ts`, which clears this
|
|
30
|
+
* several times over without coupling a layout number to a decoration one. */
|
|
31
|
+
const FOCUS_RING_WIDTH = 2;
|
|
32
|
+
|
|
26
33
|
/** THE focus ring — a 2px zinc-900 box-shadow flush against the box (offset 0),
|
|
27
34
|
* layout-neutral (never reflows). The kit no longer ships a global `:focus-visible`
|
|
28
35
|
* CSS rule, so every interactive control paints THIS inline when keyboard-focused
|
|
@@ -30,7 +37,7 @@ export const CONTROL_RADIUS = 10;
|
|
|
30
37
|
* trigger (Picker / Combobox / InlineSelect / InlineDatePicker) wears it on its open
|
|
31
38
|
* state so an open trigger reads identically to a keyboard-focused one — one token,
|
|
32
39
|
* one look. Apps building their own controls reuse it. */
|
|
33
|
-
export const FOCUS_RING = `0 0 0
|
|
40
|
+
export const FOCUS_RING = `0 0 0 ${FOCUS_RING_WIDTH}px ${colors.zinc[900]}`;
|
|
34
41
|
|
|
35
42
|
/**
|
|
36
43
|
* How far a control surface insets its OWN text: 1px of border (transparent on an
|
package/src/dialog.tsx
CHANGED
|
@@ -313,6 +313,16 @@ const styles = StyleSheet.create({
|
|
|
313
313
|
minHeight: 56,
|
|
314
314
|
},
|
|
315
315
|
scrollAreaContent: {
|
|
316
|
+
// A ScrollView clips, so the FIRST child sat flush against the boundary and lost
|
|
317
|
+
// the outer half of its focus ring — an input at the top of a dialog showed a
|
|
318
|
+
// sheared ring on the one interaction the ring exists to signal. 8 clears that
|
|
319
|
+
// several times over, but the ring is only the FLOOR here, not the reason: this
|
|
320
|
+
// is separation from the header, so it comes off the 8-grid like every other gap.
|
|
321
|
+
// `DialogHeader` already pays 8 below itself, making the visible gap a clean 16.
|
|
322
|
+
//
|
|
323
|
+
// Asymmetric with the bottom on purpose — 24 there is scroll RUNOUT, so the last
|
|
324
|
+
// row isn't jammed against the edge at the end of a scroll. Different job.
|
|
325
|
+
paddingTop: 8,
|
|
316
326
|
paddingBottom: 24,
|
|
317
327
|
},
|
|
318
328
|
footer: {
|
package/src/inline_select.tsx
CHANGED
|
@@ -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);
|
package/src/screen_router.tsx
CHANGED
|
@@ -159,6 +159,24 @@ export function useNavigationStack(
|
|
|
159
159
|
// Screen
|
|
160
160
|
// ============================================================================
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Whether the surrounding {@link Screen} is the one on top.
|
|
164
|
+
*
|
|
165
|
+
* A screen stacked BEHIND another stays mounted (`display: none`) so its scroll
|
|
166
|
+
* position survives, which means its effects keep running — including any
|
|
167
|
+
* keyboard shortcut it registered. Anything that claims a global key must read
|
|
168
|
+
* this and stand down while hidden, or the buried screen answers a keystroke the
|
|
169
|
+
* user aimed at the screen in front of it.
|
|
170
|
+
*
|
|
171
|
+
* Defaults to `true`, so a subtree outside any `Screen` behaves as if visible.
|
|
172
|
+
*/
|
|
173
|
+
const ScreenActiveContext = createContext(true);
|
|
174
|
+
|
|
175
|
+
/** @see ScreenActiveContext */
|
|
176
|
+
export function useScreenActive(): boolean {
|
|
177
|
+
return useContext(ScreenActiveContext);
|
|
178
|
+
}
|
|
179
|
+
|
|
162
180
|
export interface ScreenProps {
|
|
163
181
|
/** Route pattern to match. Use empty string "" for the default/initial screen. */
|
|
164
182
|
route: string;
|
|
@@ -208,9 +226,11 @@ export function Screen(props: ScreenProps) {
|
|
|
208
226
|
const params = parseRouteParams(route, matchingStackPath);
|
|
209
227
|
|
|
210
228
|
return (
|
|
211
|
-
<
|
|
212
|
-
{
|
|
213
|
-
|
|
229
|
+
<ScreenActiveContext.Provider value={isActive}>
|
|
230
|
+
<View style={isActive ? screenStyles.active : screenStyles.hidden}>
|
|
231
|
+
{typeof children === "function" ? children(params) : children}
|
|
232
|
+
</View>
|
|
233
|
+
</ScreenActiveContext.Provider>
|
|
214
234
|
);
|
|
215
235
|
}
|
|
216
236
|
|