@dloizides/ui-forms 1.8.0 → 1.9.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/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.0
4
+
5
+ **Campaign F3 — `Field`'s spacing model, and the layout primitives.** `Field` hard-coded a 16px
6
+ bottom margin on its container. That is the pre-flexbox model — the CHILD owns the space after
7
+ itself — and in a `gap`-laid-out parent, which is how every modern form row in this fleet is
8
+ written, the margin STACKS on the parent's gap. So consumers cancelled it by hand: **16
9
+ `containerStyle={{ marginBottom: 0 }}` sites across the fleet**, 13 of them in aml-v2, each with an
10
+ apologetic comment. AML v1 got this right in CSS years ago (`.ui-field { display: flex;
11
+ flex-direction: column; gap: 6px; }`) and wrote the contract down in `shared/FORMS.md`.
12
+
13
+ **This release moves NO pixels for any existing consumer.** The fix is opt-in, not a 2.0.0 — see
14
+ "Why not a major bump" below.
15
+
16
+ - **Add `FormGrid` + `FormCell`** — the wrapping form row, extracted from the copy the fleet
17
+ re-declares verbatim (`{ flexDirection: 'row', flexWrap: 'wrap', gap: 12 }` +
18
+ `{ minWidth: 220, flexGrow: 1, flexBasis: 260 }`), with the min-width/basis parameterised. Cell
19
+ metrics live on the GRID, so the common call site is a bare `<FormCell>` and only the genuine
20
+ one-offs (`<FormCell basis={160} grow={false} minWidth={140}>`) carry props.
21
+ - **Add `FormSection`** — the fieldset/legend grouping only v1 had. react-native-web has no
22
+ `fieldset` mapping, so this is the ARIA equivalent: `role="group"` + `aria-labelledby` pointing
23
+ at the legend. The legend reuses `resolveLabelVariantStyle` — the SAME function `Field` uses — so
24
+ a section heading cannot drift into a third label voice.
25
+ - **Add `Field.spacing` (`'stack' | 'gap'`)** — the two spacing models. `stack` is the historical
26
+ 16px margin + 4px child margins, byte-identical to 1.8.0, and remains the default. `gap` is the
27
+ FORMS.md contract: no bottom margin, one uniform 6px internal gap, parent owns the rhythm.
28
+ - **`Field` inside a `FormGrid` or `FormSection` resolves to `gap` automatically.** Adopting the
29
+ shared grid IS the opt-in: a consumer deletes its hand-rolled `grid`/`field` styles AND its
30
+ `marginBottom: 0` cancel in the same edit. An explicit `spacing` prop beats the context in both
31
+ directions.
32
+ - **`ChipSelector`**: the chip row now spaces with `gap: 8` instead of per-chip margins plus a
33
+ compensating `marginBottom: -8`. Rendered box is byte-identical. Also gains a `spacing`
34
+ passthrough, and `containerStyle` widens to `StyleProp<ViewStyle>` to match `Field`'s.
35
+
36
+ ### Why not a major bump
37
+
38
+ The brief for this work allowed a breaking 2.0.0. It was rejected. Switching every `Field` to the
39
+ gap model in one publish moves pixels on every form in seven portals simultaneously — which is
40
+ precisely the change you cannot visually QA, because no app remains on the old behaviour to compare
41
+ against, and a regression surfaces as "some form somewhere looks slightly wrong" with seven
42
+ suspects. The context-aware opt-in gives the identical end state via a per-app rollout, each step
43
+ behind visual QA, with `git revert` scoped to one app. The cost is that both models live in the
44
+ code until the fleet has migrated; that is a smaller price than an un-QA-able fleet-wide pixel
45
+ change.
46
+
47
+ ### Correction to the campaign premise
48
+
49
+ `ChipSelector`'s `marginBottom: -CHIP_GUTTER` was documented — and briefed — as compensation for
50
+ `Field`'s 16px margin, load-bearing enough that changing one without the other would break chip
51
+ spacing in 7+ sites. **That was wrong.** The -8 cancelled the chips' OWN bottom gutter hanging off
52
+ the last wrapped row; it behaved identically whatever `Field` contributed. It has been replaced with
53
+ `gap` regardless, because a margin/negative-margin pair is fragile and leaks a negative margin onto
54
+ the block's box. `fieldSpacing.test.tsx` now pins `Field` and `ChipSelector` to the same box in both
55
+ models, so the relationship is checkable rather than folkloric.
56
+
57
+
3
58
  ## 1.8.0
4
59
 
5
60
  **Campaign F2 — the dense CONTROLS arrive.** `@dloizides/ui-tables` shipped a complete, themed,
package/dist/index.d.mts CHANGED
@@ -1,6 +1,48 @@
1
1
  import React from 'react';
2
2
  import { StyleProp, TextStyle, ViewStyle, TextInputProps, TextInput, View } from 'react-native';
3
3
 
4
+ /**
5
+ * `Field`'s TWO spacing models — the F3 fix, shipped as an OPT-IN rather than a breaking change.
6
+ *
7
+ * ## The defect
8
+ *
9
+ * `Field` hard-coded a 16px bottom margin on its container and 4px margins on its label / hint /
10
+ * error. That is the pre-flexbox model: the CHILD owns the space after itself. In a `gap`-laid-out
11
+ * parent — which is how every modern form row in this fleet is written — the child's margin STACKS
12
+ * on the parent's gap, so the consumer has to cancel it by hand. aml-v2 alone carries 13
13
+ * `marginBottom: 0` cancels, each with an apologetic comment; the fleet carries 16.
14
+ *
15
+ * AML v1 got this right in CSS: `.ui-field { display: flex; flex-direction: column; gap: 6px; }` —
16
+ * internal gap only, and the parent grid owns the space BETWEEN fields. That contract is written up
17
+ * in `PROOViD/AMLService/AMLService/wwwroot/shared/FORMS.md` ("The gap is owned by the container,
18
+ * never by margins on the children").
19
+ *
20
+ * ## Why opt-in and not a 2.0.0
21
+ *
22
+ * Switching every `Field` to the gap model at once moves pixels on every form in seven portals in a
23
+ * single publish — which is exactly the change you cannot visually QA, because there is no app you
24
+ * can look at that is still on the old behaviour to compare against. The two models are therefore
25
+ * both implemented, selected per field:
26
+ *
27
+ * - `stack` (DEFAULT) — the historical margins, byte-identical to 1.8.0. Every existing consumer
28
+ * is untouched, including the 16 that currently cancel the margin.
29
+ * - `gap` — the FORMS.md contract: no bottom margin at all, uniform 6px internal gap. The parent
30
+ * owns the rhythm.
31
+ *
32
+ * A `Field` inside a `FormGrid` resolves to `gap` automatically, so adopting the shared grid IS the
33
+ * opt-in — the consumer deletes its hand-rolled `grid`/`field` styles and its margin cancel in the
34
+ * same edit, and the fleet migrates one app at a time behind visual QA.
35
+ */
36
+
37
+ /**
38
+ * Which spacing model a field speaks.
39
+ *
40
+ * A plain string union rather than a `const enum` for the same reason as `FieldLabelVariant`: this
41
+ * type crosses the package boundary into consumers' `.d.ts`, and a cross-boundary `const enum`
42
+ * breaks `isolatedModules` / Babel-only builds.
43
+ */
44
+ type FieldSpacing = 'stack' | 'gap';
45
+
4
46
  /**
5
47
  * The TWO legitimate label voices, per the shared forms contract
6
48
  * (`PROOViD/AMLService/AMLService/wwwroot/shared/FORMS.md` → "The TWO legitimate label voices").
@@ -102,9 +144,18 @@ interface FieldProps {
102
144
  * plus a `grow` modifier — instead of having to restate a COMPLETE style per variant.
103
145
  */
104
146
  containerStyle?: StyleProp<ViewStyle>;
147
+ /**
148
+ * Which of the two spacing models this field speaks — see `fieldSpacing.ts`.
149
+ *
150
+ * Leave it unset. Inside a `FormGrid` or `FormSection` it resolves to `gap` (the FORMS.md
151
+ * contract: no bottom margin, the parent's `gap` owns the rhythm); anywhere else it resolves to
152
+ * `stack` (the historical 16px margin, byte-identical to 1.8.0). Set it explicitly only to
153
+ * override that — e.g. a cell inside a grid that genuinely stacks two fields wants `stack`.
154
+ */
155
+ spacing?: FieldSpacing;
105
156
  testID?: string;
106
157
  }
107
- declare const Field: ({ label, children, required, optional, optionalLabel, hint, error, labelVariant, labelStyle, containerStyle, testID, }: FieldProps) => React.ReactElement;
158
+ declare const Field: ({ label, children, required, optional, optionalLabel, hint, error, labelVariant, labelStyle, containerStyle, spacing, testID, }: FieldProps) => React.ReactElement;
108
159
 
109
160
  /**
110
161
  * Required / optional markers, per the shared forms contract
@@ -137,6 +188,117 @@ type FieldMarker = 'none' | 'required' | 'optional';
137
188
  */
138
189
  declare const DEFAULT_OPTIONAL_LABEL = "(optional)";
139
190
 
191
+ /**
192
+ * `FormGrid` — the wrapping form row, extracted from ~177 hand-rolled copies.
193
+ *
194
+ * Verbatim in every portal, differing only in one constant:
195
+ *
196
+ * ```ts
197
+ * grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12 },
198
+ * field: { minWidth: 220, flexGrow: 1, flexBasis: 260 }, // or 200/240/260
199
+ * ```
200
+ *
201
+ * Extracting it is worth more than the deleted lines: because the grid declares itself through
202
+ * `FormGridContext`, a `Field` inside it knows its vertical rhythm is owned by the grid's `gap` and
203
+ * drops its own bottom margin. That kills the fleet's 16 `marginBottom: 0` cancel hacks at the same
204
+ * time — the grid and the cancel were always two halves of one bug.
205
+ *
206
+ * The cell metrics live on the GRID, not on each cell, because that is how the real call sites are
207
+ * written: one `styles.field` reused by every cell in the row, with the occasional narrow one-off.
208
+ * `<FormCell>` with no props is therefore the common case, and `<FormCell basis={160} grow={false}>`
209
+ * the exception — which is exactly the ratio in aml-v2's `ScreeningForm`.
210
+ */
211
+
212
+ interface FormGridProps {
213
+ children: React.ReactNode;
214
+ /**
215
+ * Space between cells, horizontally AND between wrapped rows. This is the value that makes the
216
+ * grid own the vertical rhythm, so it is also what a descendant `Field` is dropping its own
217
+ * margin in favour of.
218
+ */
219
+ gap?: number;
220
+ /** Default `minWidth` for every `FormCell` in this grid. */
221
+ minWidth?: number;
222
+ /** Default `flexBasis` for every `FormCell` in this grid. */
223
+ basis?: number;
224
+ style?: StyleProp<ViewStyle>;
225
+ testID?: string;
226
+ }
227
+ declare const FormGrid: ({ children, gap, minWidth, basis, style, testID, }: FormGridProps) => React.ReactElement;
228
+
229
+ /**
230
+ * `FormCell` — one cell of a `FormGrid`.
231
+ *
232
+ * Replaces the `<View style={styles.field}>` wrapper every real call site already puts around each
233
+ * control. It inherits `minWidth` / `flexBasis` from the enclosing grid so the common case is a bare
234
+ * `<FormCell>`, and takes per-cell overrides for the genuine one-offs (aml-v2's `fieldNarrow`:
235
+ * `minWidth: 140, flexBasis: 160, flexGrow: 0`).
236
+ *
237
+ * Used OUTSIDE a `FormGrid` it still renders a sensible cell from the package defaults rather than
238
+ * throwing — a cell is a layout hint, and a form that renders slightly wide is a better failure than
239
+ * a form that does not render. The grid-owned SPACING behaviour, by contrast, is deliberately NOT
240
+ * available this way: that one has to come from a real grid, because a `Field` dropping its margin
241
+ * with nothing to replace it would collapse the form.
242
+ */
243
+
244
+ interface FormCellProps {
245
+ children: React.ReactNode;
246
+ /** Overrides the grid's `minWidth` for this cell only. */
247
+ minWidth?: number;
248
+ /** Overrides the grid's `basis` for this cell only. */
249
+ basis?: number;
250
+ /** `false` pins the cell to its basis instead of letting it absorb the row's slack. */
251
+ grow?: boolean;
252
+ style?: StyleProp<ViewStyle>;
253
+ testID?: string;
254
+ }
255
+ declare const FormCell: ({ children, minWidth, basis, grow, style, testID, }: FormCellProps) => React.ReactElement;
256
+
257
+ /**
258
+ * `FormSection` — a titled group of fields. The React answer to v1's `<fieldset>` / `<legend>`.
259
+ *
260
+ * v1 has a real `<fieldset>` whose `<legend>` takes the identical label metric as a field label, so
261
+ * a group heading reads as part of the form rather than as page furniture. Nothing in the React
262
+ * fleet has this: zygos fakes it with a generic `Heading`, and everybody else emits an untitled
263
+ * `<View>` — which means the grouping exists visually but not semantically, so a screen-reader user
264
+ * tabbing into "Threshold" is never told it belongs to "Advanced matching".
265
+ *
266
+ * ## Why not a literal `<fieldset>`
267
+ *
268
+ * react-native-web has no `fieldset` mapping — a `View` is a `div`, and there is no escape hatch
269
+ * that turns one into a `<fieldset>` with a real `<legend>`. What IS available is the pattern the
270
+ * ARIA spec defines as the equivalent: `role="group"` + `aria-labelledby` pointing at the heading.
271
+ * Assistive tech announces "Advanced matching, group" exactly as it would for a fieldset. This is
272
+ * the honest ceiling of the platform, not a shortcut — and it is why the legend is a `Text` whose
273
+ * `nativeID` is generated the same way `Field`'s hint/error ids are.
274
+ *
275
+ * The legend deliberately reuses `resolveLabelVariantStyle` — the SAME function `Field` uses for its
276
+ * label — so the two cannot drift. A section titled in one metric above fields labelled in another
277
+ * is precisely the "third voice is drift" failure FORMS.md warns about.
278
+ */
279
+
280
+ interface FormSectionProps {
281
+ /**
282
+ * The legend — pre-localized, like every string in this package. Optional: a section with no
283
+ * title still groups its children for layout, but renders NO legend row and NO `aria-labelledby`,
284
+ * because a group labelled by an empty element is worse for assistive tech than an unlabelled one.
285
+ */
286
+ title?: string;
287
+ /** Help line for the whole group, under the legend. The group-level sibling of `Field`'s hint. */
288
+ description?: string;
289
+ children: React.ReactNode;
290
+ /**
291
+ * Which label voice the legend speaks. Defaults to `field` — a section heading on a form the user
292
+ * fills in. Pass `control` on a dense surface so the legend matches the controls beneath it.
293
+ */
294
+ labelVariant?: FieldLabelVariant;
295
+ /** Escape hatch applied AFTER `labelVariant`, for the genuine one-off a variant does not cover. */
296
+ titleStyle?: StyleProp<TextStyle>;
297
+ style?: StyleProp<ViewStyle>;
298
+ testID?: string;
299
+ }
300
+ declare const FormSection: ({ title, description, children, labelVariant, titleStyle, style, testID, }: FormSectionProps) => React.ReactElement;
301
+
140
302
  /**
141
303
  * `FormActions` — the submit/cancel button row every form ends with.
142
304
  *
@@ -289,7 +451,17 @@ interface ChipSelectorProps<T> {
289
451
  disabled?: boolean;
290
452
  /** Visual variant. `solid` (default) = filled selected pill; `outline` = v1 tinted-outline. */
291
453
  variant?: ChipVariant;
292
- containerStyle?: ViewStyle;
454
+ /**
455
+ * `StyleProp` (not a bare `ViewStyle`) so it composes the same way `Field`'s does — a caller can
456
+ * pass an array instead of restating a complete style per variant.
457
+ */
458
+ containerStyle?: StyleProp<ViewStyle>;
459
+ /**
460
+ * Forwarded verbatim to the underlying `Field`. `ChipSelector` deliberately holds NO spacing
461
+ * opinion of its own: it delegates, so the two cannot drift into disagreeing about how much room
462
+ * a chip block occupies. The guard for that is `chipSelectorFieldLockstep` in the tests.
463
+ */
464
+ spacing?: FieldSpacing;
293
465
  /** Marks the selection mandatory — renders `Field`'s decorative `*` next to the label. */
294
466
  required?: boolean;
295
467
  /** Validation message under the chips, wired to the chip group via `aria-describedby`. */
@@ -308,7 +480,7 @@ interface ChipSelectorProps<T> {
308
480
  */
309
481
  optionAccessibilityHint?: string;
310
482
  }
311
- declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, required, error, testID, chipTestIDPrefix, optionAccessibilityHint, }: ChipSelectorProps<T>) => React.ReactElement;
483
+ declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, spacing, required, error, testID, chipTestIDPrefix, optionAccessibilityHint, }: ChipSelectorProps<T>) => React.ReactElement;
312
484
 
313
485
  /**
314
486
  * Reusable form switch/toggle with an optional description.
@@ -624,4 +796,4 @@ declare function suggestOptions(options: readonly ControlOption[], query: string
624
796
  /** True when `text` is non-empty yet matches no option's value or label exactly (case-insensitive). */
625
797
  declare function isUnmatched(options: readonly ControlOption[], text: string): boolean;
626
798
 
627
- export { AnchoredMenu, type AnchoredMenuColors, type AnchoredMenuProps, type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, type ControlOption, DEFAULT_OPTIONAL_LABEL, DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS, DEFAULT_TYPEAHEAD_MIN_CHARS, DateRangeControl, type DateRangeControlProps, type DateRangeValue, Field, type FieldChildContext, type FieldChildren, type FieldLabelVariant, type FieldMarker, type FieldProps, FormActions, type FormActionsProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormSwitch, type FormSwitchProps, SelectControl, type SelectControlProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, TypeaheadControl, type TypeaheadControlProps, type UseThemedInputOptions, controlStyles, isUnmatched, suggestOptions, useThemedInput };
799
+ export { AnchoredMenu, type AnchoredMenuColors, type AnchoredMenuProps, type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, type ControlOption, DEFAULT_OPTIONAL_LABEL, DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS, DEFAULT_TYPEAHEAD_MIN_CHARS, DateRangeControl, type DateRangeControlProps, type DateRangeValue, Field, type FieldChildContext, type FieldChildren, type FieldLabelVariant, type FieldMarker, type FieldProps, type FieldSpacing, FormActions, type FormActionsProps, FormCell, type FormCellProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormGrid, type FormGridProps, FormSection, type FormSectionProps, FormSwitch, type FormSwitchProps, SelectControl, type SelectControlProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, TypeaheadControl, type TypeaheadControlProps, type UseThemedInputOptions, controlStyles, isUnmatched, suggestOptions, useThemedInput };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,48 @@
1
1
  import React from 'react';
2
2
  import { StyleProp, TextStyle, ViewStyle, TextInputProps, TextInput, View } from 'react-native';
3
3
 
4
+ /**
5
+ * `Field`'s TWO spacing models — the F3 fix, shipped as an OPT-IN rather than a breaking change.
6
+ *
7
+ * ## The defect
8
+ *
9
+ * `Field` hard-coded a 16px bottom margin on its container and 4px margins on its label / hint /
10
+ * error. That is the pre-flexbox model: the CHILD owns the space after itself. In a `gap`-laid-out
11
+ * parent — which is how every modern form row in this fleet is written — the child's margin STACKS
12
+ * on the parent's gap, so the consumer has to cancel it by hand. aml-v2 alone carries 13
13
+ * `marginBottom: 0` cancels, each with an apologetic comment; the fleet carries 16.
14
+ *
15
+ * AML v1 got this right in CSS: `.ui-field { display: flex; flex-direction: column; gap: 6px; }` —
16
+ * internal gap only, and the parent grid owns the space BETWEEN fields. That contract is written up
17
+ * in `PROOViD/AMLService/AMLService/wwwroot/shared/FORMS.md` ("The gap is owned by the container,
18
+ * never by margins on the children").
19
+ *
20
+ * ## Why opt-in and not a 2.0.0
21
+ *
22
+ * Switching every `Field` to the gap model at once moves pixels on every form in seven portals in a
23
+ * single publish — which is exactly the change you cannot visually QA, because there is no app you
24
+ * can look at that is still on the old behaviour to compare against. The two models are therefore
25
+ * both implemented, selected per field:
26
+ *
27
+ * - `stack` (DEFAULT) — the historical margins, byte-identical to 1.8.0. Every existing consumer
28
+ * is untouched, including the 16 that currently cancel the margin.
29
+ * - `gap` — the FORMS.md contract: no bottom margin at all, uniform 6px internal gap. The parent
30
+ * owns the rhythm.
31
+ *
32
+ * A `Field` inside a `FormGrid` resolves to `gap` automatically, so adopting the shared grid IS the
33
+ * opt-in — the consumer deletes its hand-rolled `grid`/`field` styles and its margin cancel in the
34
+ * same edit, and the fleet migrates one app at a time behind visual QA.
35
+ */
36
+
37
+ /**
38
+ * Which spacing model a field speaks.
39
+ *
40
+ * A plain string union rather than a `const enum` for the same reason as `FieldLabelVariant`: this
41
+ * type crosses the package boundary into consumers' `.d.ts`, and a cross-boundary `const enum`
42
+ * breaks `isolatedModules` / Babel-only builds.
43
+ */
44
+ type FieldSpacing = 'stack' | 'gap';
45
+
4
46
  /**
5
47
  * The TWO legitimate label voices, per the shared forms contract
6
48
  * (`PROOViD/AMLService/AMLService/wwwroot/shared/FORMS.md` → "The TWO legitimate label voices").
@@ -102,9 +144,18 @@ interface FieldProps {
102
144
  * plus a `grow` modifier — instead of having to restate a COMPLETE style per variant.
103
145
  */
104
146
  containerStyle?: StyleProp<ViewStyle>;
147
+ /**
148
+ * Which of the two spacing models this field speaks — see `fieldSpacing.ts`.
149
+ *
150
+ * Leave it unset. Inside a `FormGrid` or `FormSection` it resolves to `gap` (the FORMS.md
151
+ * contract: no bottom margin, the parent's `gap` owns the rhythm); anywhere else it resolves to
152
+ * `stack` (the historical 16px margin, byte-identical to 1.8.0). Set it explicitly only to
153
+ * override that — e.g. a cell inside a grid that genuinely stacks two fields wants `stack`.
154
+ */
155
+ spacing?: FieldSpacing;
105
156
  testID?: string;
106
157
  }
107
- declare const Field: ({ label, children, required, optional, optionalLabel, hint, error, labelVariant, labelStyle, containerStyle, testID, }: FieldProps) => React.ReactElement;
158
+ declare const Field: ({ label, children, required, optional, optionalLabel, hint, error, labelVariant, labelStyle, containerStyle, spacing, testID, }: FieldProps) => React.ReactElement;
108
159
 
109
160
  /**
110
161
  * Required / optional markers, per the shared forms contract
@@ -137,6 +188,117 @@ type FieldMarker = 'none' | 'required' | 'optional';
137
188
  */
138
189
  declare const DEFAULT_OPTIONAL_LABEL = "(optional)";
139
190
 
191
+ /**
192
+ * `FormGrid` — the wrapping form row, extracted from ~177 hand-rolled copies.
193
+ *
194
+ * Verbatim in every portal, differing only in one constant:
195
+ *
196
+ * ```ts
197
+ * grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12 },
198
+ * field: { minWidth: 220, flexGrow: 1, flexBasis: 260 }, // or 200/240/260
199
+ * ```
200
+ *
201
+ * Extracting it is worth more than the deleted lines: because the grid declares itself through
202
+ * `FormGridContext`, a `Field` inside it knows its vertical rhythm is owned by the grid's `gap` and
203
+ * drops its own bottom margin. That kills the fleet's 16 `marginBottom: 0` cancel hacks at the same
204
+ * time — the grid and the cancel were always two halves of one bug.
205
+ *
206
+ * The cell metrics live on the GRID, not on each cell, because that is how the real call sites are
207
+ * written: one `styles.field` reused by every cell in the row, with the occasional narrow one-off.
208
+ * `<FormCell>` with no props is therefore the common case, and `<FormCell basis={160} grow={false}>`
209
+ * the exception — which is exactly the ratio in aml-v2's `ScreeningForm`.
210
+ */
211
+
212
+ interface FormGridProps {
213
+ children: React.ReactNode;
214
+ /**
215
+ * Space between cells, horizontally AND between wrapped rows. This is the value that makes the
216
+ * grid own the vertical rhythm, so it is also what a descendant `Field` is dropping its own
217
+ * margin in favour of.
218
+ */
219
+ gap?: number;
220
+ /** Default `minWidth` for every `FormCell` in this grid. */
221
+ minWidth?: number;
222
+ /** Default `flexBasis` for every `FormCell` in this grid. */
223
+ basis?: number;
224
+ style?: StyleProp<ViewStyle>;
225
+ testID?: string;
226
+ }
227
+ declare const FormGrid: ({ children, gap, minWidth, basis, style, testID, }: FormGridProps) => React.ReactElement;
228
+
229
+ /**
230
+ * `FormCell` — one cell of a `FormGrid`.
231
+ *
232
+ * Replaces the `<View style={styles.field}>` wrapper every real call site already puts around each
233
+ * control. It inherits `minWidth` / `flexBasis` from the enclosing grid so the common case is a bare
234
+ * `<FormCell>`, and takes per-cell overrides for the genuine one-offs (aml-v2's `fieldNarrow`:
235
+ * `minWidth: 140, flexBasis: 160, flexGrow: 0`).
236
+ *
237
+ * Used OUTSIDE a `FormGrid` it still renders a sensible cell from the package defaults rather than
238
+ * throwing — a cell is a layout hint, and a form that renders slightly wide is a better failure than
239
+ * a form that does not render. The grid-owned SPACING behaviour, by contrast, is deliberately NOT
240
+ * available this way: that one has to come from a real grid, because a `Field` dropping its margin
241
+ * with nothing to replace it would collapse the form.
242
+ */
243
+
244
+ interface FormCellProps {
245
+ children: React.ReactNode;
246
+ /** Overrides the grid's `minWidth` for this cell only. */
247
+ minWidth?: number;
248
+ /** Overrides the grid's `basis` for this cell only. */
249
+ basis?: number;
250
+ /** `false` pins the cell to its basis instead of letting it absorb the row's slack. */
251
+ grow?: boolean;
252
+ style?: StyleProp<ViewStyle>;
253
+ testID?: string;
254
+ }
255
+ declare const FormCell: ({ children, minWidth, basis, grow, style, testID, }: FormCellProps) => React.ReactElement;
256
+
257
+ /**
258
+ * `FormSection` — a titled group of fields. The React answer to v1's `<fieldset>` / `<legend>`.
259
+ *
260
+ * v1 has a real `<fieldset>` whose `<legend>` takes the identical label metric as a field label, so
261
+ * a group heading reads as part of the form rather than as page furniture. Nothing in the React
262
+ * fleet has this: zygos fakes it with a generic `Heading`, and everybody else emits an untitled
263
+ * `<View>` — which means the grouping exists visually but not semantically, so a screen-reader user
264
+ * tabbing into "Threshold" is never told it belongs to "Advanced matching".
265
+ *
266
+ * ## Why not a literal `<fieldset>`
267
+ *
268
+ * react-native-web has no `fieldset` mapping — a `View` is a `div`, and there is no escape hatch
269
+ * that turns one into a `<fieldset>` with a real `<legend>`. What IS available is the pattern the
270
+ * ARIA spec defines as the equivalent: `role="group"` + `aria-labelledby` pointing at the heading.
271
+ * Assistive tech announces "Advanced matching, group" exactly as it would for a fieldset. This is
272
+ * the honest ceiling of the platform, not a shortcut — and it is why the legend is a `Text` whose
273
+ * `nativeID` is generated the same way `Field`'s hint/error ids are.
274
+ *
275
+ * The legend deliberately reuses `resolveLabelVariantStyle` — the SAME function `Field` uses for its
276
+ * label — so the two cannot drift. A section titled in one metric above fields labelled in another
277
+ * is precisely the "third voice is drift" failure FORMS.md warns about.
278
+ */
279
+
280
+ interface FormSectionProps {
281
+ /**
282
+ * The legend — pre-localized, like every string in this package. Optional: a section with no
283
+ * title still groups its children for layout, but renders NO legend row and NO `aria-labelledby`,
284
+ * because a group labelled by an empty element is worse for assistive tech than an unlabelled one.
285
+ */
286
+ title?: string;
287
+ /** Help line for the whole group, under the legend. The group-level sibling of `Field`'s hint. */
288
+ description?: string;
289
+ children: React.ReactNode;
290
+ /**
291
+ * Which label voice the legend speaks. Defaults to `field` — a section heading on a form the user
292
+ * fills in. Pass `control` on a dense surface so the legend matches the controls beneath it.
293
+ */
294
+ labelVariant?: FieldLabelVariant;
295
+ /** Escape hatch applied AFTER `labelVariant`, for the genuine one-off a variant does not cover. */
296
+ titleStyle?: StyleProp<TextStyle>;
297
+ style?: StyleProp<ViewStyle>;
298
+ testID?: string;
299
+ }
300
+ declare const FormSection: ({ title, description, children, labelVariant, titleStyle, style, testID, }: FormSectionProps) => React.ReactElement;
301
+
140
302
  /**
141
303
  * `FormActions` — the submit/cancel button row every form ends with.
142
304
  *
@@ -289,7 +451,17 @@ interface ChipSelectorProps<T> {
289
451
  disabled?: boolean;
290
452
  /** Visual variant. `solid` (default) = filled selected pill; `outline` = v1 tinted-outline. */
291
453
  variant?: ChipVariant;
292
- containerStyle?: ViewStyle;
454
+ /**
455
+ * `StyleProp` (not a bare `ViewStyle`) so it composes the same way `Field`'s does — a caller can
456
+ * pass an array instead of restating a complete style per variant.
457
+ */
458
+ containerStyle?: StyleProp<ViewStyle>;
459
+ /**
460
+ * Forwarded verbatim to the underlying `Field`. `ChipSelector` deliberately holds NO spacing
461
+ * opinion of its own: it delegates, so the two cannot drift into disagreeing about how much room
462
+ * a chip block occupies. The guard for that is `chipSelectorFieldLockstep` in the tests.
463
+ */
464
+ spacing?: FieldSpacing;
293
465
  /** Marks the selection mandatory — renders `Field`'s decorative `*` next to the label. */
294
466
  required?: boolean;
295
467
  /** Validation message under the chips, wired to the chip group via `aria-describedby`. */
@@ -308,7 +480,7 @@ interface ChipSelectorProps<T> {
308
480
  */
309
481
  optionAccessibilityHint?: string;
310
482
  }
311
- declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, required, error, testID, chipTestIDPrefix, optionAccessibilityHint, }: ChipSelectorProps<T>) => React.ReactElement;
483
+ declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, spacing, required, error, testID, chipTestIDPrefix, optionAccessibilityHint, }: ChipSelectorProps<T>) => React.ReactElement;
312
484
 
313
485
  /**
314
486
  * Reusable form switch/toggle with an optional description.
@@ -624,4 +796,4 @@ declare function suggestOptions(options: readonly ControlOption[], query: string
624
796
  /** True when `text` is non-empty yet matches no option's value or label exactly (case-insensitive). */
625
797
  declare function isUnmatched(options: readonly ControlOption[], text: string): boolean;
626
798
 
627
- export { AnchoredMenu, type AnchoredMenuColors, type AnchoredMenuProps, type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, type ControlOption, DEFAULT_OPTIONAL_LABEL, DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS, DEFAULT_TYPEAHEAD_MIN_CHARS, DateRangeControl, type DateRangeControlProps, type DateRangeValue, Field, type FieldChildContext, type FieldChildren, type FieldLabelVariant, type FieldMarker, type FieldProps, FormActions, type FormActionsProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormSwitch, type FormSwitchProps, SelectControl, type SelectControlProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, TypeaheadControl, type TypeaheadControlProps, type UseThemedInputOptions, controlStyles, isUnmatched, suggestOptions, useThemedInput };
799
+ export { AnchoredMenu, type AnchoredMenuColors, type AnchoredMenuProps, type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, type ControlOption, DEFAULT_OPTIONAL_LABEL, DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS, DEFAULT_TYPEAHEAD_MIN_CHARS, DateRangeControl, type DateRangeControlProps, type DateRangeValue, Field, type FieldChildContext, type FieldChildren, type FieldLabelVariant, type FieldMarker, type FieldProps, type FieldSpacing, FormActions, type FormActionsProps, FormCell, type FormCellProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormGrid, type FormGridProps, FormSection, type FormSectionProps, FormSwitch, type FormSwitchProps, SelectControl, type SelectControlProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, TypeaheadControl, type TypeaheadControlProps, type UseThemedInputOptions, controlStyles, isUnmatched, suggestOptions, useThemedInput };