@lotics/ui 26.1.0 → 26.4.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
@@ -649,7 +649,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
649
649
  - **`use_form`** — `useForm`: THE batch draft-form state hook — `values` = `initialValues` +
650
650
  an edits overlay (a revalidation refreshes untouched fields, no sync effect), `validate`
651
651
  (sync/async, gates submit, editing clears the field's error), `onSubmit(values, helpers)`
652
- with a re-entry-guarded `submitting`, and `changes`/`changed` the touched-fields diff
652
+ with a re-entry-guarded `submitting` an error `onSubmit` reports itself via
653
+ `setFieldError` (a rejected write, say) SURVIVES, because each attempt clears the previous
654
+ attempt's errors on the way IN, not on the way out — and `changes`/`changed` — the touched-fields diff
653
655
  that feeds DIFF-writes (send only edited fields); `setFieldValue` (curried or direct) +
654
656
  `setValues`/`setFieldError`/`reset`. Pairs with the Form\* family for dialog/settings
655
657
  forms — the draft-validate-COMMIT-together twin of the self-persisting Inline\* editors.
@@ -830,7 +832,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
830
832
  grammar); raise `minValueWidth` (default 160) when a cell holds
831
833
  MORE than one editor so the table stacks earlier. Two tables on one page share one grid by
832
834
  repeating the same labelWidth. Worked example:
833
- [`tpl_record`](../examples/tpl_record.tsx).
835
+ [`tpl_record`](../examples/tpl_record.tsx). A label's FIRST line centres on the control line, not the label block: a wrapped label grows DOWNWARD past the value's first line rather than dragging its own first line above it, so "Registered address" over two lines still starts level with the value it names.
834
836
  - **`record_summary`** — `RecordSummary`: the identity band of a record detail/drawer — ONE
835
837
  row: `title` xxl semibold tabular, `subtitle` sm muted, `status` Badge slot, optional
836
838
  `metric` {label,value,tone,note} pinned right, the band's ONE accent. The record's FIELDS
@@ -854,7 +856,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
854
856
  seam: **left, the LINK** — `Change` (`onChange`, `secondary`) points the field at a different
855
857
  record, named for the intent and deliberately not `danger` since a recoverable correction must
856
858
  not wear the colour reserved for destruction; `Clear` (`onClear`, NO fill — the least-reached
857
- verb of the four) leaves it empty. **Both are REQUIRED**, so the footer has one shape
859
+ verb of the four) leaves it empty. The draft's footer follows the same rule: `Cancel` carries
860
+ no fill beside a filled `Save`, so the only filled button in either mode is the one that acts. **Both are REQUIRED**, so the footer has one shape
858
861
  everywhere; a reference the record cannot do without gains nothing from hiding Clear, because
859
862
  Change and then declining to pick reaches the same empty state. **Right, the RECORD it points at** — `Edit` (`primary`, the one filled-dark rung,
860
863
  handed to `Save` when the draft opens) and `Open` (`secondary`, rightmost, OPTIONAL, the
@@ -1085,7 +1088,16 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1085
1088
  never dismiss it — clicks, scrolls, and Escape belong to that layer until it closes. Only the
1086
1089
  `small` (bottom-sheet) presentation is modal (scrim). `PopoverContent` already insets its
1087
1090
  body 12px — put content directly in it, NEVER add your own padding `View` (that double-pads);
1088
- title/actions go in `PopoverHeader` / `PopoverFooter`.
1091
+ title/actions go in `PopoverHeader` / `PopoverFooter`. **A fixed width is the `width` PROP,
1092
+ never `style`**: `style` reaches only the BODY, and the panel is `max-content`, so a width set
1093
+ there governs the scroller while the header and footer sit outside it — the first long title
1094
+ then stretches the panel and leaves the content adrift in a box wider than itself. A popover
1095
+ with no bands never sees this, which is why `width` had to exist before one had both.
1096
+ The built-in scroller is **full-bleed horizontally** and re-insets its content by the same
1097
+ amount, so a scrolling body reaches the panel's real edges — its content slides under the
1098
+ panel edge rather than under an invisible margin 12px inside it — while the text stays on the
1099
+ same column the header and footer use. Nothing to opt into; `disableBodyScroll` consumers
1100
+ (which own their scroll) are untouched.
1089
1101
  - **`popover_nav`** — `usePopoverNav` + `PopoverScreen` + `PopoverNavHeader` — the popover's
1090
1102
  built-in mini-router: EVERY `Popover` provides the nav context (`navigate(route)` pushes,
1091
1103
  `goBack`, `currentRoute`, `canGoBack`; resets on close), `PopoverScreen route=""` is the
@@ -738,7 +738,7 @@ function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onUnset, on
738
738
  { label: "Contact", value: rec.contact, name: "contact" },
739
739
  { label: "City", value: rec.city, name: "city" },
740
740
  { label: "Tax ID", value: rec.taxId, name: "taxId" },
741
- { label: "Address", value: rec.address, name: "address", multiline: true },
741
+ { label: "Registered address", value: rec.address, name: "address", multiline: true },
742
742
  { label: "Customer since", value: rec.since, name: "since", type: "date" },
743
743
  ]}
744
744
  accessibilityLabel={`${rec.name} — details`}
@@ -1719,7 +1719,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
1719
1719
  { label: "Tax ID", value: customer.taxId, name: "taxId" },
1720
1720
  { label: "Contact", value: customer.contact, name: "contact" },
1721
1721
  { label: "City", value: customer.city, name: "city" },
1722
- { label: "Address", value: customer.address, name: "address", multiline: true },
1722
+ { label: "Registered address", value: customer.address, name: "address", multiline: true },
1723
1723
  { label: "Customer since", value: customer.since, name: "since", type: "date" },
1724
1724
  ]}
1725
1725
  accessibilityLabel={`${customer.name} — details`}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "26.1.0",
3
+ "version": "26.4.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -136,8 +136,17 @@ export function DetailRow(props: DetailRowProps) {
136
136
  // so the perceived gap matches a chip row's. Horizontal mode only (stacked
137
137
  // mode has no band; it mirrors FormField's order).
138
138
  const flatTuck = flat && annotated ? -Math.max(0, Math.round((minHeight - getInputLineHeight(false)) / 2)) : 0;
139
+ // A label CENTERS in the control line — but only its FIRST line does. Centring
140
+ // the whole block is identical while the label fits on one line and wrong the
141
+ // moment it wraps: a two-line label fills the band, so its first line starts at
142
+ // the band's top while the value's still sits at the control's own text inset,
143
+ // and the pair reads nine pixels out of step. Pad down by what centring a
144
+ // single line would have cost, then top-align: one line lands exactly where it
145
+ // always did, and every extra line grows downward past the value's first line
146
+ // instead of dragging it upward.
147
+ const labelLead = Math.max(0, Math.round((minHeight - getInputLineHeight(false)) / 2));
139
148
  const labelCell = (cellStyle: StyleProp<ViewStyle>) => (
140
- <View style={[styles.labelCell, { minHeight }, cellStyle]}>
149
+ <View style={[styles.labelCell, { minHeight, paddingTop: labelLead }, cellStyle]}>
141
150
  {/* The label WRAPS inside its column — it is NEVER clipped. A field name
142
151
  the reader can't finish is worse than a taller row, and a fixed
143
152
  `labelWidth` guarantees the clip on every long name ("Registered
@@ -237,7 +246,9 @@ const styles = StyleSheet.create({
237
246
  // tall as the band or taller, so it fills it and its FIRST line tops out
238
247
  // level with the value's first control line. Do not "fix" this to
239
248
  // `flex-start` — that drops every single-line label to the band's top edge.
240
- labelCell: { justifyContent: "center" },
249
+ // Top-aligned, with `labelLead` doing the single-line centring — see the
250
+ // note at its definition for why centring the BLOCK breaks a wrapped label.
251
+ labelCell: { justifyContent: "flex-start" },
241
252
  // The first line of the value cell — the control centers in it exactly like
242
253
  // the label cell does.
243
254
  controlLine: { justifyContent: "center" },
package/src/popover.tsx CHANGED
@@ -51,6 +51,16 @@ function usePopoverContext() {
51
51
  }
52
52
 
53
53
  // Root Popover component
54
+ /**
55
+ * The panel's own inset. The footer bleeds out by it to span its divider full
56
+ * width, and the scroll body does the same to reach the panel's real edges —
57
+ * three places that must agree, so they read it from here rather than each
58
+ * spelling `12`.
59
+ */
60
+ const PANEL_INSET = 12;
61
+ const SCROLL_BODY = { marginHorizontal: -PANEL_INSET } as const;
62
+ const SCROLL_BODY_CONTENT = { paddingHorizontal: PANEL_INSET } as const;
63
+
54
64
  export interface PopoverProps {
55
65
  open?: boolean;
56
66
  onOpenChange?: (open: boolean) => void;
@@ -187,6 +197,17 @@ export interface PopoverContentProps {
187
197
  contentContainerStyle?: StyleProp<ViewStyle>;
188
198
  /** When true, body renders in a plain View instead of ScrollView (use when children manage their own scroll) */
189
199
  disableBodyScroll?: boolean;
200
+ /**
201
+ * A fixed panel width, in px, covering ALL THREE BANDS.
202
+ *
203
+ * `style` reaches only the BODY, so a width set there governs the scroller and
204
+ * leaves `PopoverHeader`/`PopoverFooter` outside it — and the panel is
205
+ * `max-content`, so the first long title stretches the panel while the body
206
+ * stays put, leaving the content adrift in a box wider than itself. A popover
207
+ * with no bands never sees this, which is why it went unnoticed until one had
208
+ * both.
209
+ */
210
+ width?: number;
190
211
  /** When true, renders as bottom sheet on small screens (close button, slide-up animation) */
191
212
  small?: boolean;
192
213
  /** Accessible name for the bottom-sheet close button. Defaults to the locale's `overlay.close` ("Close" / "Đóng"). */
@@ -207,6 +228,7 @@ export function PopoverContent(props: PopoverContentProps) {
207
228
  style,
208
229
  contentContainerStyle,
209
230
  disableBodyScroll,
231
+ width,
210
232
  small = false,
211
233
  closeLabel = locale.overlay.close,
212
234
  manageFocus = true,
@@ -602,7 +624,7 @@ export function PopoverContent(props: PopoverContentProps) {
602
624
  tabIndex={small ? undefined : -1}
603
625
  style={{
604
626
  position: "fixed",
605
- padding: 12,
627
+ padding: PANEL_INSET,
606
628
  borderTopLeftRadius: 16,
607
629
  borderTopRightRadius: 16,
608
630
  borderBottomLeftRadius: small ? 0 : 16,
@@ -664,6 +686,8 @@ export function PopoverContent(props: PopoverContentProps) {
664
686
  width: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
665
687
  maxWidth: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
666
688
  }),
689
+ // Last, so it beats the `max-content` every branch above sets.
690
+ ...(width != null && !small ? { width, maxWidth: width } : null),
667
691
  }}
668
692
  onClick={(e) => e.stopPropagation()}
669
693
  // React synthetic events bubble through portals via the REACT tree, so a
@@ -692,7 +716,21 @@ export function PopoverContent(props: PopoverContentProps) {
692
716
  {disableBodyScroll ? (
693
717
  <View style={style}>{bodyChildren}</View>
694
718
  ) : (
695
- <ScrollView style={style} contentContainerStyle={contentContainerStyle}>
719
+ // FULL-BLEED HORIZONTALLY, then re-inset by the same 12. The panel
720
+ // pads all four sides, so a scroller sitting inside that padding
721
+ // clips its content 12px short of the edge and parks the scrollbar
722
+ // there too — content slides under an invisible margin instead of
723
+ // under the panel's own edge. Pulling out and padding back keeps the
724
+ // text on the exact column the header and footer use while giving the
725
+ // scroll its real edges. `PopoverFooter` already does this dance for
726
+ // its divider; the body needed it for the same reason.
727
+ //
728
+ // Horizontal only: the vertical padding is the gap to the header and
729
+ // footer, which is a gap the reader wants.
730
+ <ScrollView
731
+ style={[SCROLL_BODY, style]}
732
+ contentContainerStyle={[SCROLL_BODY_CONTENT, contentContainerStyle]}
733
+ >
696
734
  {bodyChildren}
697
735
  </ScrollView>
698
736
  )}
@@ -719,9 +757,9 @@ export function PopoverFooter(props: PopoverFooterProps) {
719
757
  return (
720
758
  // Pull out to the popover's edges (counteract its 12px inset) so the divider
721
759
  // spans full width; the action row then re-insets to align with the body.
722
- <View style={{ marginHorizontal: -12, marginTop: 12 }}>
760
+ <View style={{ marginHorizontal: -PANEL_INSET, marginTop: PANEL_INSET }}>
723
761
  {showDivider && <Divider />}
724
- <View style={{ paddingHorizontal: 12, paddingTop: 12, flexDirection: "row", alignItems: "center", gap: 8, justifyContent }}>
762
+ <View style={{ paddingHorizontal: PANEL_INSET, paddingTop: PANEL_INSET, flexDirection: "row", alignItems: "center", gap: 8, justifyContent }}>
725
763
  {children}
726
764
  </View>
727
765
  </View>
@@ -274,7 +274,11 @@ export function ReferenceField(props: ReferenceFieldProps) {
274
274
  is invisible at three facts and fatal at eleven: the panel ran past the
275
275
  viewport with no way to scroll, carrying its own Save button off-screen
276
276
  with it. The three-fact fixture is what hid it. */}
277
- <PopoverContent style={{ width: 320, maxHeight: 420 }}>
277
+ {/* `width` on the PANEL, not `style` — `style` reaches only the body, so a
278
+ long referenced name in the header stretched the panel past it and the
279
+ facts sat in a box wider than themselves. `maxHeight` stays on the
280
+ body: that is the band that scrolls. */}
281
+ <PopoverContent width={320} style={{ maxHeight: 420 }}>
278
282
  <PopoverHeader>
279
283
  <DialogSectionHeadingTitle description={code}>{name}</DialogSectionHeadingTitle>
280
284
  </PopoverHeader>
@@ -378,7 +382,11 @@ export function ReferenceField(props: ReferenceFieldProps) {
378
382
  <PopoverFooter align={editing ? "end" : "space-between"}>
379
383
  {editing ? (
380
384
  <>
381
- <Button title={t.cancel} color="secondary" disabled={saving} onPress={closeDraft} />
385
+ {/* No fill, the same as `Clear` in the resting footer: in a pair of
386
+ one quiet exit and one commit, the exit carries no box. Save is
387
+ the only filled button in this mode, so nothing competes with
388
+ the act that writes. */}
389
+ <Button title={t.cancel} disabled={saving} onPress={closeDraft} />
382
390
  {/* Disabled until something DIFFERS: with nothing to send, a save
383
391
  is a write that fires the record's hooks and bumps its
384
392
  modified stamp to record that nobody changed anything. */}
package/src/use_form.ts CHANGED
@@ -116,9 +116,14 @@ function reducer<T>(prevState: FormState<T>, action: Action<T>): FormState<T> {
116
116
  if (prevState.submitting === action.submitting) return prevState;
117
117
  return { ...prevState, submitting: action.submitting };
118
118
  case "SUBMIT":
119
+ // Deliberately does NOT clear errors. This fires after `onSubmit` returns,
120
+ // and a handler that caught a server rejection and called `setFieldError`
121
+ // returns normally — so wiping here erased the only report of the failure
122
+ // and the press looked like it did nothing. Nothing needs the wipe:
123
+ // editing a field clears that field's error, and a `validate` pass
124
+ // replaces the whole set.
119
125
  return {
120
126
  ...prevState,
121
- errors: {},
122
127
  submitting: false,
123
128
  submitCount: prevState.submitCount + 1,
124
129
  };
@@ -228,14 +233,14 @@ export function useForm<T>(props: UseFormProps<T>): FormHandler<T> {
228
233
  if (!onSubmit) return;
229
234
  if (submittingRef.current) return;
230
235
 
231
- if (validate !== undefined) {
232
- const nextErrors = await validate(values);
233
-
234
- if (nextErrors && Object.keys(nextErrors).length) {
235
- setErrors(nextErrors);
236
- return;
237
- }
238
- }
236
+ // The previous attempt's errors are cleared HERE, before this one runs —
237
+ // not after `onSubmit` returns. Clearing afterwards erased the very error a
238
+ // handler had just set from a rejected write, so the press looked like it
239
+ // did nothing; never clearing left that error on screen after a later
240
+ // attempt succeeded. Clearing up front does both jobs.
241
+ const nextErrors = validate ? ((await validate(values)) ?? {}) : {};
242
+ setErrors(nextErrors);
243
+ if (Object.keys(nextErrors).length) return;
239
244
 
240
245
  submittingRef.current = true;
241
246
  dispatch({ type: "SET_SUBMITTING", submitting: true });