@dloizides/ui-forms 1.9.1 → 1.12.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/CHANGELOG.md CHANGED
@@ -1,280 +1,327 @@
1
- # Changelog
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
-
58
- ## 1.8.0
59
-
60
- **Campaign F2 — the dense CONTROLS arrive.** `@dloizides/ui-tables` shipped a complete, themed,
61
- a11y-wired set of form controls inside its `Filters` bar and exported NONE of them: its
62
- `src/index.ts` exposed only `Filters`, `useFilterDraft`, `suggestOptions`, `isUnmatched` and
63
- types. Six portals could SEE those controls working and none could import them, so the fleet
64
- reimplemented them — **6 independent select implementations, 5 date fields, 2 typeaheads**. Same
65
- shape as `MultiTenancy.EntityFrameworkCore` shipping no EF Core. This release makes them public.
66
-
67
- Nothing here moves a pixel for an existing consumer: the metrics are the promoted originals,
68
- unchanged. `CONTAINER_MARGIN_BOTTOM` and `ChipSelector`'s compensating `marginBottom:
69
- -CHIP_GUTTER` remain deliberately untouched.
70
-
71
- - **Add `SelectControl`** — single-select enum/status dropdown. Bordered trigger + chevron opening
72
- the shared `AnchoredMenu`.
73
- - **Add `TypeaheadControl`** — search-as-you-type combobox (aml-v2's CountryPicker, generalized).
74
- Free typing is preserved (the value is raw text the caller normalises at query time); picking a
75
- suggestion fills the canonical LABEL, not the value.
76
- - **Add `DateRangeControl`** — inclusive from/to pair. Editing one side patches ONLY that side.
77
- - **Add `AnchoredMenu`** — the floating option list both of the above compose. Kept in-tree with
78
- NO `ui-layout` dependency; that was a considered choice in `ui-tables` and it still holds, since
79
- depending on the higher-level `ModalDropdown` would invert the graph for every consumer.
80
- - **Add `controlStyles`** — the tuned metrics, promoted verbatim. `ui-tables`' `Filters/styles.ts`
81
- said the quiet part out loud in its own header: it "reconcile[d] the per-field styles that were
82
- copy-pasted across aml-v2's filter files into one shared source" — and then locked the
83
- reconciliation inside one bar where no other surface could reach it.
84
- - **Add `suggestOptions` / `isUnmatched`** — the ranked substring matcher behind the typeahead.
85
- - **Add `ControlOption` / `DateRangeValue`** types. `ui-tables` now ALIASES its long-standing
86
- public `FilterOption` / `DateRangeValue` onto these, so there is one definition rather than two
87
- structurally-compatible twins free to drift.
88
-
89
- ### The controls are LABEL-FREE, on purpose
90
-
91
- They render the control only. Compose them inside `Field` — with `labelVariant="control"` on a
92
- dense surface — to get the label, hint and error slots. That is what retires `ui-tables`' private
93
- `FieldShell` fork (see `@dloizides/ui-tables` 1.14.0) and leaves ONE label implementation for
94
- every control instead of one per control.
95
-
96
- ### i18n: this package still never calls `t`
97
-
98
- Every string — `accessibilityLabel`, `placeholder`, `optionHint`, `error`, `fromLabel`/`toLabel`
99
- — is a PRE-LOCALIZED prop. This is load-bearing, not stylistic: aml-v2 localizes through
100
- `@dloizides/i18n` with positional `{0}` while the other six portals use i18next `{{p1}}`, so a
101
- component that localized internally could not serve both. `SelectControl`'s `accessibilityLabel`
102
- is therefore composed BY THE CALLER — it must carry the selection ("Status: Active", not
103
- "Status"), and building that string needs a `t` call this package must not make.
104
-
105
- ### Fixed — a WCAG defect carried in by the promoted code
106
-
107
- `AnchoredMenu` marked the current option with `accessibilityState={{ selected }}` alone.
108
- **react-native-web does not read `accessibilityState` at all** (verified against
109
- `react-native-web@0.21`'s `createDOMProps`, which enumerates `aria-selected` /
110
- `accessibilitySelected` and never `accessibilityState`), so on web the selected option was
111
- distinguished by colour and font-weight ONLY — no screen reader could tell which option was
112
- current (WCAG 1.4.1, use of colour). `aria-selected` is now passed alongside, so each platform
113
- gets the channel it honours. The trigger already paired `aria-expanded` with `accessibilityState`;
114
- the option row was simply missed when that was done. A test asserts it, and that test FAILED
115
- before the fix.
116
-
117
- ## 1.7.0
118
-
119
- Additive wave implementing the shared cross-stack forms contract now written down at
120
- `PROOViD/AMLService/AMLService/wwwroot/shared/FORMS.md`. **Nothing in this release moves a pixel
121
- for an existing consumer**: every addition is opt-in and every default is the previous behaviour.
122
- `CONTAINER_MARGIN_BOTTOM` and `ChipSelector`'s compensating `marginBottom: -CHIP_GUTTER` are
123
- deliberately untouched — they are load-bearing and move together in a later, visual-QA-gated wave.
124
-
125
- - **Add `FormActions` — the submit/cancel row.** Promoted from the BYTE-IDENTICAL
126
- `erevna-web/src/components/Forms/FormActions.tsx` and `katalogos-web/.../FormActions.tsx`
127
- (68 lines each; `diff` returns nothing). All seven portals hand-roll this row; aml-v2, agora,
128
- kefi, ichnos and zygos each inline their own variant. The primary action is LAST in DOM order so
129
- tab reaches Cancel before Save, and `saving` drives the primary button's spinner rather than
130
- disabling-and-relabelling it. `saveDisabled` blocks Save WITHOUT blocking Cancel — an invalid
131
- form stays escapable. testIDs default to the twins' `save-button` / `cancel-button`, so existing
132
- Playwright selectors keep matching.
133
- - **`Field` gains `hint`.** ~21 hand-rolled copies of `{fontSize:12, marginTop:4, color:
134
- textSecondary}` exist across the fleet; aml-v2 alone declares a local `hint` style in 8 files.
135
- Because `Field` had no hint slot, consumers rendered the hint as a SIBLING, where it lands past
136
- the container's 16px bottom margin and detaches from the control it explains (see the apologetic
137
- comment in `aml-v2/src/screens/screening/CoverageSelector.tsx`). The hint renders UNDER the
138
- control and ABOVE the error, and hint and error COEXIST — both are reachable from the control via
139
- a composed `aria-describedby` (hint id then error id, in reading order). Only the error is a
140
- `role="alert"` live region; a hint that announced itself would interrupt on every focus.
141
- - **`Field` gains `optional` + `optionalLabel`.** Mutually exclusive with `required`; when a caller
142
- sets both, `required` WINS — the safe direction, since an under-marked required field sends the
143
- user into a failed submit. Enforced at runtime in `resolveFieldMarker` rather than in the type
144
- system, so a caller spreading a computed props object still gets a defined single-marker result
145
- instead of a compile error it cannot act on. Like the asterisk, the optional text is decorative
146
- (`aria-hidden`) — "optional" is the ABSENCE of `aria-required`, which assistive tech already
147
- conveys.
148
- - **`Field` gains `labelVariant` (+ `labelStyle` as the escape hatch).** `Field` hard-coded
149
- 13/600/sentence-case, which is exactly why kefi-web refused it and why `@dloizides/ui-tables`'
150
- private `FieldShell` forked to 11/700/uppercase. `labelVariant` makes the contract's TWO
151
- legitimate voices first-class and named: `field` (13/600 sentence case — a label that is READ)
152
- and `control` (11/700 UPPERCASE + letter-spacing — a label that is SCANNED, for dense filter bars
153
- and toolbars). An absent variant resolves to `field`, so every existing consumer renders
154
- byte-identically. `labelStyle` applies AFTER the variant for the genuine one-off.
155
- - **`Field`'s `containerStyle` widens `ViewStyle` -> `StyleProp<ViewStyle>`.** The narrow type was a
156
- documented pain: `aml-v2/src/screens/leaders/CountryPicker.tsx` has to build a COMPLETE style per
157
- variant because it cannot compose an array.
158
- - **`useThemedInput`: a focused errored field now keeps a RED focus ring.** It previously computed
159
- `isFocused && !hasError`, so an errored field got NO focus ring at all — removing the focus
160
- indicator (WCAG 2.4.7) from precisely the field the user was sent back to fix, at the moment they
161
- are most likely navigating by keyboard. It now always rings on focus and only changes the ring's
162
- COLOUR, matching v1's `input.invalid:focus { box-shadow: 0 0 0 3px var(--danger-tint) }`.
163
- One pre-existing test necessarily changed: it asserted `boxShadow === 'none'` for this state,
164
- i.e. it encoded the defect as intended behaviour.
165
- - **New peer dependency `@dloizides/ui-buttons >= 1.4.0`**, required by `FormActions` so the estate
166
- keeps exactly one button implementation rather than forking one here.
167
- - No i18n runtime is introduced: all user-visible copy (`saveLabel`, `cancelLabel`, `optionalLabel`,
168
- `hint`, `error`) arrives pre-localized. aml-v2 localizes via `@dloizides/i18n` with positional
169
- `{0}` params while the other six apps use i18next `{{p1}}`, and nothing bridges them, so a
170
- component that localized internally could not serve both.
171
- - Also shipping in this tarball, from a CONCURRENT workstream (commit `13bd281`, not part of this
172
- wave): `ChipSelector` gains `chipTestIDPrefix`, `optionAccessibilityHint` and `multiple`
173
- toggle semantics, and reads its on-primary ink from the theme's `onBrand` scale instead of a
174
- hardcoded `#fff`. All opt-in; the `chip-selector-chip-<value>` testID default is frozen.
175
-
176
- ## 1.6.0
177
-
178
- - **`ChipSelector` now composes `Field` internally — its label was drifting.** It rendered its OWN
179
- `<Text>` label whose metrics differed from `Field`'s on three of four axes: `marginBottom: 8`
180
- (vs 4), NO `fontSize` at all so it fell back to RN's ~14 (vs 13), and `colors.text`
181
- (vs `textSecondary`). A `ChipSelector` next to a `FormField` in a row/grid therefore started a
182
- different distance down the column and read as a different weight of text. Found at 4 sites in
183
- aml-v2 (`TenantWatchlistsEditor`, `CoverageSelector`, `CustomCoverageFields`,
184
- `AdvancedMatching`); fixing it in the package fixes every consumer with zero app churn.
185
- The local label styles are deleted — there is now exactly one label implementation in the kit.
186
- - **`ChipSelectorProps` is unchanged and fully backward-compatible** — every existing prop keeps
187
- its name, type and default, and `label` stays OPTIONAL. With no label (or an empty one) NO label
188
- row is rendered, so composing `Field` cannot introduce a phantom gap above the chips.
189
- - **`ChipSelector` gains `required`, `error` and `testID`** (all optional, all additive) now that
190
- `Field` provides them for free. `error` renders the same `role="alert"` line as `FormField` and
191
- is tied to the chip group via `aria-describedby` + `aria-invalid` (web only; no-op on native).
192
- - **Spacing:** a `ChipSelector` now carries `Field`'s `marginBottom: 16` like every other field
193
- (it previously had none). The chips' own 8px trailing gutter is cancelled on the group so the
194
- block's total bottom spacing is exactly 16 — the same as a `FormField`, not 24. Pass
195
- `containerStyle` to override, as before.
196
- - **`Field`'s `label` is now optional** (widened from `label: string`, so existing callers are
197
- unaffected). An absent or empty label renders no label row, letting a control whose label lives
198
- elsewhere still use the shared shell for its error line and spacing.
199
- - `FormSwitch` / `FormCheckbox` are deliberately NOT changed — see `src/labelRoles.test.tsx`.
200
- Their labels are INLINE control labels (beside the control in a row, the row's primary text),
201
- not field headers stacked above a control, so they have no alignment defect to fix and `Field`'s
202
- smaller/dimmer header treatment would be a regression. They were already identical to each
203
- other; a new test now enforces that they stay so.
204
-
205
- ## 1.5.0
206
-
207
- - **Add `Field` — the generic label-over-control wrapper.** `FormField` is hard-wired to a text
208
- input (`FormFieldProps extends Omit<TextInputProps,'style'>`), so any NON-text control (a
209
- dropdown, a date picker, a chip selector) had no shared way to get a label — and ended up
210
- visually misaligned next to its `FormField` siblings: the control's box started ~21px higher
211
- (no label row) and it missed `FormField`'s `marginBottom: 16`, so wrapped rows lost their
212
- vertical rhythm. This was hand-rolled in 5 places across zygos-web and aml-v2.
213
- `Field` takes `{ label, required?, error?, children, containerStyle?, testID? }` and renders the
214
- same label row / required mark / error line / spacing.
215
- - **`FormField` now composes `Field` internally**, so the two can never drift apart — the metrics
216
- live in exactly one place. `FormFieldProps` and every rendered attribute (`form-field-input`
217
- testID, `aria-invalid` / `aria-describedby` / `aria-required`, the `role="alert"` error line)
218
- are unchanged: fully backward-compatible.
219
- - `Field`'s `children` may be a plain node (the common case) or a render function receiving
220
- `{ describedById, hasError }`, so a custom control can wire the error line into its own
221
- `aria-describedby` / invalid state exactly the way `FormField` does.
222
-
223
- ## 1.4.0
224
-
225
- - **Accessibility (WCAG 2.1 AA) hardening — additive + backward-compatible.**
226
- - `FormField`: the error message is now programmatically tied to the input — the input gets
227
- `aria-invalid` when errored and `aria-describedby` pointing at the error line (which is a
228
- `role="alert"` live region), and `aria-required` when `required`. The required `*` is marked
229
- decorative (`aria-hidden` / `accessibilityElementsHidden`) so a screen reader hears "required"
230
- (via `aria-required`) instead of "star".
231
- - `ThemedTextInput`: new optional `describedById` + `requiredField` props forward
232
- `aria-describedby` / `aria-required`, and it emits `aria-invalid` from `hasError` (web only;
233
- no-ops on native). Existing callers are unaffected.
234
- - `FormCheckbox`: now keyboard-operable — the `role="checkbox"` Pressable renders a `<div>`
235
- (no free Enter/Space), so an explicit web-only Enter/Space handler toggles it (WCAG 2.1.1).
236
- - `ChipSelector`: chips gain a `hitSlop` so the touch target reaches ~44px WITHOUT changing the
237
- rendered pill size, and expose `accessibilityState.disabled` when the selector is disabled.
238
- - `useThemedInput`: the focus/hover CSS transition now collapses to instant when the user
239
- requests `prefers-reduced-motion: reduce` (WCAG 2.3.3, web only).
240
-
241
- ## 1.3.0
242
-
243
- - `ChipSelector` gains a `variant` prop (`'solid' | 'outline'`, default `'solid'`). `solid` is the
244
- original filled-pill look (selected = solid brand fill, white ink) — existing consumers
245
- (erevna / katalogos / kefi) render **identically**, no change. `outline` is the AML v1 console
246
- look: an outlined pill on a muted fill where the **selected** state is a subtle tinted-outline
247
- (brand border + brand ink + a low-alpha brand wash), NOT a solid fill; on web, hover lifts the
248
- border + ink to the brand colour. Fully theme-driven (brand colour + alpha, border/text from the
249
- theme), so it re-themes per tenant. Additive + backward-compatible.
250
-
251
- ## 1.2.0
252
-
253
- - Add `ThemedTextInput` + `useThemedInput` — the shared "readable + lively" text-input treatment,
254
- driven entirely by the active theme (re-themes per tenant, no hard-coded hex). At rest: dark
255
- readable text on a subtle off-white surface with a soft border. On hover (web): the border
256
- strengthens a touch. On focus: the border becomes the brand primary, the background brightens to
257
- the plain surface (white), and a soft 3px brand-tinted focus ring fades in. The animation is a
258
- pure GPU-cheap CSS transition on `border-color`/`background-color`/`box-shadow` (the ring is a
259
- `box-shadow`, so no layout/reflow) — NO `Animated`/`LayoutAnimation`/JS timers. Web-only style
260
- keys and the mouse-hover handlers are guarded behind `Platform.OS === 'web'`, so it stays
261
- native-safe (native applies the state-driven colours instantly).
262
- - `FormField` now renders `ThemedTextInput`, so every field gets the treatment for free; its label
263
- uses `textSecondary` at 13px/600 for readability. Props/behaviour (label, hint, error, testID,
264
- a11y, `onFocus`/`onBlur` pass-through) are backward-compatible.
265
-
266
- ## 1.1.0
267
-
268
- - Add `FormCheckbox` — a native-safe (`Pressable`-based, RN-web + native) themed checkbox: a rounded
269
- square that fills with the brand colour and shows a check glyph when on, with a label and optional
270
- hint. Mirrors `FormSwitch`'s prop shape (`value`, `onValueChange`/`onChange`, `label`, `hint`,
271
- `disabled`, `testID`, a11y) so it is a drop-in for boolean toggles that should read as checkboxes.
272
- Exposes `role="checkbox"` + `aria-checked` / `accessibilityState={{ checked }}`.
273
-
274
- ## 1.0.0
275
-
276
- Initial release. Extracted the proven duplicated form components from erevna-web ↔ katalogos-web
277
- (Capability Wave C1, batch 2).
278
-
279
- - `FormField`, `ChipSelector`, `FormSwitch` — share the `@dloizides/ui-feedback` UI context (`useUi`).
280
- - `FormActions` deferred (depends on a not-yet-shared `core/Button`).
1
+ # Changelog
2
+
3
+ ## 1.12.0
4
+
5
+ **`FormField` gained `labelHidden` — hide the visible label, keep the accessible name.**
6
+
7
+ A field whose placeholder already names it (a toolbar search box, an inline filter) does
8
+ not want a stacked visible label: the label wastes a row and, worse, pushes the input a
9
+ label-row lower than a bare button beside it, so the two misalign. Until now the only way
10
+ to drop the visible label was to blank `label` — which ALSO blanked the input's
11
+ `accessibilityLabel`, silently stripping the accessible name.
12
+
13
+ `labelHidden` removes ONLY the visible `<label>` row: `label` stays required and the input
14
+ keeps `accessibilityLabel={label}`, so a screen reader reads the same name and the field
15
+ still resolves by role + name. Additive and backward-compatible — default `false`, every
16
+ existing call renders unchanged.
17
+
18
+ ## 1.11.1
19
+
20
+ **`ThemedTextInput` shipped no metrics, so every direct caller got a raw 19px input.**
21
+
22
+ It documented itself as "the ONE source of truth for the input look" while shipping
23
+ only **colours** — no border, radius, padding or height. `FormField` quietly passed its
24
+ own private duplicate of those metrics, so anything reached *through* `FormField` looked
25
+ correct and the gap was invisible. Every **direct** caller — the documented, blessed
26
+ usage — got a raw browser input.
27
+
28
+ Measured live in the Digital Kin CMS before: **10/10 inputs below the 48px floor**, text
29
+ inputs 19px, textareas 38px. After: **0/10**, at 48/96px.
30
+
31
+ **Label association was absent entirely** (0 `label[for]`, 0 ids). Two defects only a
32
+ real browser could find, both fixed here:
33
+
34
+ - **`htmlFor` is silently dropped by react-native-web** — it is not on the prop
35
+ allowlist, the same family as the dropped `accessibilityHint`. A prop-level assertion
36
+ passes against the broken build; only a DOM-level test catches it, so that is what the
37
+ new tests assert.
38
+ - **Dangling `for`.** `Field` assumed render-function children place the id they are
39
+ handed, but `SelectControl` uses `aria-labelledby` instead — so the difficulty label
40
+ shipped `for="field-control-3"` pointing at **nothing**, passing every structural check
41
+ while focusing no field. `useHtmlFor` now sets `for` only when the target is genuinely
42
+ in the document.
43
+
44
+ > ⚠️ **Cross-fleet visual change.** `controlStyles.input` gaining `minHeight: 48` lifts
45
+ > dense filter inputs and select triggers in `aml-v2`/`agora-web` from ~38-41px to 48px.
46
+ > This cannot be avoided: the input floor is what fixes the 19px defect, and nothing
47
+ > fixes that without also lifting inputs that already had padding. Owner-approved.
48
+
49
+
50
+ ## 1.9.0
51
+
52
+ **Campaign F3 — `Field`'s spacing model, and the layout primitives.** `Field` hard-coded a 16px
53
+ bottom margin on its container. That is the pre-flexbox model — the CHILD owns the space after
54
+ itself — and in a `gap`-laid-out parent, which is how every modern form row in this fleet is
55
+ written, the margin STACKS on the parent's gap. So consumers cancelled it by hand: **16
56
+ `containerStyle={{ marginBottom: 0 }}` sites across the fleet**, 13 of them in aml-v2, each with an
57
+ apologetic comment. AML v1 got this right in CSS years ago (`.ui-field { display: flex;
58
+ flex-direction: column; gap: 6px; }`) and wrote the contract down in `shared/FORMS.md`.
59
+
60
+ **This release moves NO pixels for any existing consumer.** The fix is opt-in, not a 2.0.0 — see
61
+ "Why not a major bump" below.
62
+
63
+ - **Add `FormGrid` + `FormCell`** — the wrapping form row, extracted from the copy the fleet
64
+ re-declares verbatim (`{ flexDirection: 'row', flexWrap: 'wrap', gap: 12 }` +
65
+ `{ minWidth: 220, flexGrow: 1, flexBasis: 260 }`), with the min-width/basis parameterised. Cell
66
+ metrics live on the GRID, so the common call site is a bare `<FormCell>` and only the genuine
67
+ one-offs (`<FormCell basis={160} grow={false} minWidth={140}>`) carry props.
68
+ - **Add `FormSection`** — the fieldset/legend grouping only v1 had. react-native-web has no
69
+ `fieldset` mapping, so this is the ARIA equivalent: `role="group"` + `aria-labelledby` pointing
70
+ at the legend. The legend reuses `resolveLabelVariantStyle` — the SAME function `Field` uses — so
71
+ a section heading cannot drift into a third label voice.
72
+ - **Add `Field.spacing` (`'stack' | 'gap'`)** — the two spacing models. `stack` is the historical
73
+ 16px margin + 4px child margins, byte-identical to 1.8.0, and remains the default. `gap` is the
74
+ FORMS.md contract: no bottom margin, one uniform 6px internal gap, parent owns the rhythm.
75
+ - **`Field` inside a `FormGrid` or `FormSection` resolves to `gap` automatically.** Adopting the
76
+ shared grid IS the opt-in: a consumer deletes its hand-rolled `grid`/`field` styles AND its
77
+ `marginBottom: 0` cancel in the same edit. An explicit `spacing` prop beats the context in both
78
+ directions.
79
+ - **`ChipSelector`**: the chip row now spaces with `gap: 8` instead of per-chip margins plus a
80
+ compensating `marginBottom: -8`. Rendered box is byte-identical. Also gains a `spacing`
81
+ passthrough, and `containerStyle` widens to `StyleProp<ViewStyle>` to match `Field`'s.
82
+
83
+ ### Why not a major bump
84
+
85
+ The brief for this work allowed a breaking 2.0.0. It was rejected. Switching every `Field` to the
86
+ gap model in one publish moves pixels on every form in seven portals simultaneously — which is
87
+ precisely the change you cannot visually QA, because no app remains on the old behaviour to compare
88
+ against, and a regression surfaces as "some form somewhere looks slightly wrong" with seven
89
+ suspects. The context-aware opt-in gives the identical end state via a per-app rollout, each step
90
+ behind visual QA, with `git revert` scoped to one app. The cost is that both models live in the
91
+ code until the fleet has migrated; that is a smaller price than an un-QA-able fleet-wide pixel
92
+ change.
93
+
94
+ ### Correction to the campaign premise
95
+
96
+ `ChipSelector`'s `marginBottom: -CHIP_GUTTER` was documented — and briefed — as compensation for
97
+ `Field`'s 16px margin, load-bearing enough that changing one without the other would break chip
98
+ spacing in 7+ sites. **That was wrong.** The -8 cancelled the chips' OWN bottom gutter hanging off
99
+ the last wrapped row; it behaved identically whatever `Field` contributed. It has been replaced with
100
+ `gap` regardless, because a margin/negative-margin pair is fragile and leaks a negative margin onto
101
+ the block's box. `fieldSpacing.test.tsx` now pins `Field` and `ChipSelector` to the same box in both
102
+ models, so the relationship is checkable rather than folkloric.
103
+
104
+
105
+ ## 1.8.0
106
+
107
+ **Campaign F2 — the dense CONTROLS arrive.** `@dloizides/ui-tables` shipped a complete, themed,
108
+ a11y-wired set of form controls inside its `Filters` bar and exported NONE of them: its
109
+ `src/index.ts` exposed only `Filters`, `useFilterDraft`, `suggestOptions`, `isUnmatched` and
110
+ types. Six portals could SEE those controls working and none could import them, so the fleet
111
+ reimplemented them — **6 independent select implementations, 5 date fields, 2 typeaheads**. Same
112
+ shape as `MultiTenancy.EntityFrameworkCore` shipping no EF Core. This release makes them public.
113
+
114
+ Nothing here moves a pixel for an existing consumer: the metrics are the promoted originals,
115
+ unchanged. `CONTAINER_MARGIN_BOTTOM` and `ChipSelector`'s compensating `marginBottom:
116
+ -CHIP_GUTTER` remain deliberately untouched.
117
+
118
+ - **Add `SelectControl`** — single-select enum/status dropdown. Bordered trigger + chevron opening
119
+ the shared `AnchoredMenu`.
120
+ - **Add `TypeaheadControl`** — search-as-you-type combobox (aml-v2's CountryPicker, generalized).
121
+ Free typing is preserved (the value is raw text the caller normalises at query time); picking a
122
+ suggestion fills the canonical LABEL, not the value.
123
+ - **Add `DateRangeControl`** — inclusive from/to pair. Editing one side patches ONLY that side.
124
+ - **Add `AnchoredMenu`** — the floating option list both of the above compose. Kept in-tree with
125
+ NO `ui-layout` dependency; that was a considered choice in `ui-tables` and it still holds, since
126
+ depending on the higher-level `ModalDropdown` would invert the graph for every consumer.
127
+ - **Add `controlStyles`** — the tuned metrics, promoted verbatim. `ui-tables`' `Filters/styles.ts`
128
+ said the quiet part out loud in its own header: it "reconcile[d] the per-field styles that were
129
+ copy-pasted across aml-v2's filter files into one shared source" — and then locked the
130
+ reconciliation inside one bar where no other surface could reach it.
131
+ - **Add `suggestOptions` / `isUnmatched`** — the ranked substring matcher behind the typeahead.
132
+ - **Add `ControlOption` / `DateRangeValue`** types. `ui-tables` now ALIASES its long-standing
133
+ public `FilterOption` / `DateRangeValue` onto these, so there is one definition rather than two
134
+ structurally-compatible twins free to drift.
135
+
136
+ ### The controls are LABEL-FREE, on purpose
137
+
138
+ They render the control only. Compose them inside `Field` — with `labelVariant="control"` on a
139
+ dense surface — to get the label, hint and error slots. That is what retires `ui-tables`' private
140
+ `FieldShell` fork (see `@dloizides/ui-tables` 1.14.0) and leaves ONE label implementation for
141
+ every control instead of one per control.
142
+
143
+ ### i18n: this package still never calls `t`
144
+
145
+ Every string — `accessibilityLabel`, `placeholder`, `optionHint`, `error`, `fromLabel`/`toLabel`
146
+ — is a PRE-LOCALIZED prop. This is load-bearing, not stylistic: aml-v2 localizes through
147
+ `@dloizides/i18n` with positional `{0}` while the other six portals use i18next `{{p1}}`, so a
148
+ component that localized internally could not serve both. `SelectControl`'s `accessibilityLabel`
149
+ is therefore composed BY THE CALLER — it must carry the selection ("Status: Active", not
150
+ "Status"), and building that string needs a `t` call this package must not make.
151
+
152
+ ### Fixed — a WCAG defect carried in by the promoted code
153
+
154
+ `AnchoredMenu` marked the current option with `accessibilityState={{ selected }}` alone.
155
+ **react-native-web does not read `accessibilityState` at all** (verified against
156
+ `react-native-web@0.21`'s `createDOMProps`, which enumerates `aria-selected` /
157
+ `accessibilitySelected` and never `accessibilityState`), so on web the selected option was
158
+ distinguished by colour and font-weight ONLY — no screen reader could tell which option was
159
+ current (WCAG 1.4.1, use of colour). `aria-selected` is now passed alongside, so each platform
160
+ gets the channel it honours. The trigger already paired `aria-expanded` with `accessibilityState`;
161
+ the option row was simply missed when that was done. A test asserts it, and that test FAILED
162
+ before the fix.
163
+
164
+ ## 1.7.0
165
+
166
+ Additive wave implementing the shared cross-stack forms contract now written down at
167
+ `PROOViD/AMLService/AMLService/wwwroot/shared/FORMS.md`. **Nothing in this release moves a pixel
168
+ for an existing consumer**: every addition is opt-in and every default is the previous behaviour.
169
+ `CONTAINER_MARGIN_BOTTOM` and `ChipSelector`'s compensating `marginBottom: -CHIP_GUTTER` are
170
+ deliberately untouched — they are load-bearing and move together in a later, visual-QA-gated wave.
171
+
172
+ - **Add `FormActions` — the submit/cancel row.** Promoted from the BYTE-IDENTICAL
173
+ `erevna-web/src/components/Forms/FormActions.tsx` and `katalogos-web/.../FormActions.tsx`
174
+ (68 lines each; `diff` returns nothing). All seven portals hand-roll this row; aml-v2, agora,
175
+ kefi, ichnos and zygos each inline their own variant. The primary action is LAST in DOM order so
176
+ tab reaches Cancel before Save, and `saving` drives the primary button's spinner rather than
177
+ disabling-and-relabelling it. `saveDisabled` blocks Save WITHOUT blocking Cancel — an invalid
178
+ form stays escapable. testIDs default to the twins' `save-button` / `cancel-button`, so existing
179
+ Playwright selectors keep matching.
180
+ - **`Field` gains `hint`.** ~21 hand-rolled copies of `{fontSize:12, marginTop:4, color:
181
+ textSecondary}` exist across the fleet; aml-v2 alone declares a local `hint` style in 8 files.
182
+ Because `Field` had no hint slot, consumers rendered the hint as a SIBLING, where it lands past
183
+ the container's 16px bottom margin and detaches from the control it explains (see the apologetic
184
+ comment in `aml-v2/src/screens/screening/CoverageSelector.tsx`). The hint renders UNDER the
185
+ control and ABOVE the error, and hint and error COEXIST — both are reachable from the control via
186
+ a composed `aria-describedby` (hint id then error id, in reading order). Only the error is a
187
+ `role="alert"` live region; a hint that announced itself would interrupt on every focus.
188
+ - **`Field` gains `optional` + `optionalLabel`.** Mutually exclusive with `required`; when a caller
189
+ sets both, `required` WINS — the safe direction, since an under-marked required field sends the
190
+ user into a failed submit. Enforced at runtime in `resolveFieldMarker` rather than in the type
191
+ system, so a caller spreading a computed props object still gets a defined single-marker result
192
+ instead of a compile error it cannot act on. Like the asterisk, the optional text is decorative
193
+ (`aria-hidden`) — "optional" is the ABSENCE of `aria-required`, which assistive tech already
194
+ conveys.
195
+ - **`Field` gains `labelVariant` (+ `labelStyle` as the escape hatch).** `Field` hard-coded
196
+ 13/600/sentence-case, which is exactly why kefi-web refused it and why `@dloizides/ui-tables`'
197
+ private `FieldShell` forked to 11/700/uppercase. `labelVariant` makes the contract's TWO
198
+ legitimate voices first-class and named: `field` (13/600 sentence case — a label that is READ)
199
+ and `control` (11/700 UPPERCASE + letter-spacing — a label that is SCANNED, for dense filter bars
200
+ and toolbars). An absent variant resolves to `field`, so every existing consumer renders
201
+ byte-identically. `labelStyle` applies AFTER the variant for the genuine one-off.
202
+ - **`Field`'s `containerStyle` widens `ViewStyle` -> `StyleProp<ViewStyle>`.** The narrow type was a
203
+ documented pain: `aml-v2/src/screens/leaders/CountryPicker.tsx` has to build a COMPLETE style per
204
+ variant because it cannot compose an array.
205
+ - **`useThemedInput`: a focused errored field now keeps a RED focus ring.** It previously computed
206
+ `isFocused && !hasError`, so an errored field got NO focus ring at all — removing the focus
207
+ indicator (WCAG 2.4.7) from precisely the field the user was sent back to fix, at the moment they
208
+ are most likely navigating by keyboard. It now always rings on focus and only changes the ring's
209
+ COLOUR, matching v1's `input.invalid:focus { box-shadow: 0 0 0 3px var(--danger-tint) }`.
210
+ One pre-existing test necessarily changed: it asserted `boxShadow === 'none'` for this state,
211
+ i.e. it encoded the defect as intended behaviour.
212
+ - **New peer dependency `@dloizides/ui-buttons >= 1.4.0`**, required by `FormActions` so the estate
213
+ keeps exactly one button implementation rather than forking one here.
214
+ - No i18n runtime is introduced: all user-visible copy (`saveLabel`, `cancelLabel`, `optionalLabel`,
215
+ `hint`, `error`) arrives pre-localized. aml-v2 localizes via `@dloizides/i18n` with positional
216
+ `{0}` params while the other six apps use i18next `{{p1}}`, and nothing bridges them, so a
217
+ component that localized internally could not serve both.
218
+ - Also shipping in this tarball, from a CONCURRENT workstream (commit `13bd281`, not part of this
219
+ wave): `ChipSelector` gains `chipTestIDPrefix`, `optionAccessibilityHint` and `multiple`
220
+ toggle semantics, and reads its on-primary ink from the theme's `onBrand` scale instead of a
221
+ hardcoded `#fff`. All opt-in; the `chip-selector-chip-<value>` testID default is frozen.
222
+
223
+ ## 1.6.0
224
+
225
+ - **`ChipSelector` now composes `Field` internally — its label was drifting.** It rendered its OWN
226
+ `<Text>` label whose metrics differed from `Field`'s on three of four axes: `marginBottom: 8`
227
+ (vs 4), NO `fontSize` at all so it fell back to RN's ~14 (vs 13), and `colors.text`
228
+ (vs `textSecondary`). A `ChipSelector` next to a `FormField` in a row/grid therefore started a
229
+ different distance down the column and read as a different weight of text. Found at 4 sites in
230
+ aml-v2 (`TenantWatchlistsEditor`, `CoverageSelector`, `CustomCoverageFields`,
231
+ `AdvancedMatching`); fixing it in the package fixes every consumer with zero app churn.
232
+ The local label styles are deleted — there is now exactly one label implementation in the kit.
233
+ - **`ChipSelectorProps` is unchanged and fully backward-compatible** — every existing prop keeps
234
+ its name, type and default, and `label` stays OPTIONAL. With no label (or an empty one) NO label
235
+ row is rendered, so composing `Field` cannot introduce a phantom gap above the chips.
236
+ - **`ChipSelector` gains `required`, `error` and `testID`** (all optional, all additive) now that
237
+ `Field` provides them for free. `error` renders the same `role="alert"` line as `FormField` and
238
+ is tied to the chip group via `aria-describedby` + `aria-invalid` (web only; no-op on native).
239
+ - **Spacing:** a `ChipSelector` now carries `Field`'s `marginBottom: 16` like every other field
240
+ (it previously had none). The chips' own 8px trailing gutter is cancelled on the group so the
241
+ block's total bottom spacing is exactly 16 — the same as a `FormField`, not 24. Pass
242
+ `containerStyle` to override, as before.
243
+ - **`Field`'s `label` is now optional** (widened from `label: string`, so existing callers are
244
+ unaffected). An absent or empty label renders no label row, letting a control whose label lives
245
+ elsewhere still use the shared shell for its error line and spacing.
246
+ - `FormSwitch` / `FormCheckbox` are deliberately NOT changed — see `src/labelRoles.test.tsx`.
247
+ Their labels are INLINE control labels (beside the control in a row, the row's primary text),
248
+ not field headers stacked above a control, so they have no alignment defect to fix and `Field`'s
249
+ smaller/dimmer header treatment would be a regression. They were already identical to each
250
+ other; a new test now enforces that they stay so.
251
+
252
+ ## 1.5.0
253
+
254
+ - **Add `Field` — the generic label-over-control wrapper.** `FormField` is hard-wired to a text
255
+ input (`FormFieldProps extends Omit<TextInputProps,'style'>`), so any NON-text control (a
256
+ dropdown, a date picker, a chip selector) had no shared way to get a label — and ended up
257
+ visually misaligned next to its `FormField` siblings: the control's box started ~21px higher
258
+ (no label row) and it missed `FormField`'s `marginBottom: 16`, so wrapped rows lost their
259
+ vertical rhythm. This was hand-rolled in 5 places across zygos-web and aml-v2.
260
+ `Field` takes `{ label, required?, error?, children, containerStyle?, testID? }` and renders the
261
+ same label row / required mark / error line / spacing.
262
+ - **`FormField` now composes `Field` internally**, so the two can never drift apart — the metrics
263
+ live in exactly one place. `FormFieldProps` and every rendered attribute (`form-field-input`
264
+ testID, `aria-invalid` / `aria-describedby` / `aria-required`, the `role="alert"` error line)
265
+ are unchanged: fully backward-compatible.
266
+ - `Field`'s `children` may be a plain node (the common case) or a render function receiving
267
+ `{ describedById, hasError }`, so a custom control can wire the error line into its own
268
+ `aria-describedby` / invalid state exactly the way `FormField` does.
269
+
270
+ ## 1.4.0
271
+
272
+ - **Accessibility (WCAG 2.1 AA) hardening — additive + backward-compatible.**
273
+ - `FormField`: the error message is now programmatically tied to the input — the input gets
274
+ `aria-invalid` when errored and `aria-describedby` pointing at the error line (which is a
275
+ `role="alert"` live region), and `aria-required` when `required`. The required `*` is marked
276
+ decorative (`aria-hidden` / `accessibilityElementsHidden`) so a screen reader hears "required"
277
+ (via `aria-required`) instead of "star".
278
+ - `ThemedTextInput`: new optional `describedById` + `requiredField` props forward
279
+ `aria-describedby` / `aria-required`, and it emits `aria-invalid` from `hasError` (web only;
280
+ no-ops on native). Existing callers are unaffected.
281
+ - `FormCheckbox`: now keyboard-operable — the `role="checkbox"` Pressable renders a `<div>`
282
+ (no free Enter/Space), so an explicit web-only Enter/Space handler toggles it (WCAG 2.1.1).
283
+ - `ChipSelector`: chips gain a `hitSlop` so the touch target reaches ~44px WITHOUT changing the
284
+ rendered pill size, and expose `accessibilityState.disabled` when the selector is disabled.
285
+ - `useThemedInput`: the focus/hover CSS transition now collapses to instant when the user
286
+ requests `prefers-reduced-motion: reduce` (WCAG 2.3.3, web only).
287
+
288
+ ## 1.3.0
289
+
290
+ - `ChipSelector` gains a `variant` prop (`'solid' | 'outline'`, default `'solid'`). `solid` is the
291
+ original filled-pill look (selected = solid brand fill, white ink) — existing consumers
292
+ (erevna / katalogos / kefi) render **identically**, no change. `outline` is the AML v1 console
293
+ look: an outlined pill on a muted fill where the **selected** state is a subtle tinted-outline
294
+ (brand border + brand ink + a low-alpha brand wash), NOT a solid fill; on web, hover lifts the
295
+ border + ink to the brand colour. Fully theme-driven (brand colour + alpha, border/text from the
296
+ theme), so it re-themes per tenant. Additive + backward-compatible.
297
+
298
+ ## 1.2.0
299
+
300
+ - Add `ThemedTextInput` + `useThemedInput` — the shared "readable + lively" text-input treatment,
301
+ driven entirely by the active theme (re-themes per tenant, no hard-coded hex). At rest: dark
302
+ readable text on a subtle off-white surface with a soft border. On hover (web): the border
303
+ strengthens a touch. On focus: the border becomes the brand primary, the background brightens to
304
+ the plain surface (white), and a soft 3px brand-tinted focus ring fades in. The animation is a
305
+ pure GPU-cheap CSS transition on `border-color`/`background-color`/`box-shadow` (the ring is a
306
+ `box-shadow`, so no layout/reflow) — NO `Animated`/`LayoutAnimation`/JS timers. Web-only style
307
+ keys and the mouse-hover handlers are guarded behind `Platform.OS === 'web'`, so it stays
308
+ native-safe (native applies the state-driven colours instantly).
309
+ - `FormField` now renders `ThemedTextInput`, so every field gets the treatment for free; its label
310
+ uses `textSecondary` at 13px/600 for readability. Props/behaviour (label, hint, error, testID,
311
+ a11y, `onFocus`/`onBlur` pass-through) are backward-compatible.
312
+
313
+ ## 1.1.0
314
+
315
+ - Add `FormCheckbox` — a native-safe (`Pressable`-based, RN-web + native) themed checkbox: a rounded
316
+ square that fills with the brand colour and shows a check glyph when on, with a label and optional
317
+ hint. Mirrors `FormSwitch`'s prop shape (`value`, `onValueChange`/`onChange`, `label`, `hint`,
318
+ `disabled`, `testID`, a11y) so it is a drop-in for boolean toggles that should read as checkboxes.
319
+ Exposes `role="checkbox"` + `aria-checked` / `accessibilityState={{ checked }}`.
320
+
321
+ ## 1.0.0
322
+
323
+ Initial release. Extracted the proven duplicated form components from erevna-web ↔ katalogos-web
324
+ (Capability Wave C1, batch 2).
325
+
326
+ - `FormField`, `ChipSelector`, `FormSwitch` — share the `@dloizides/ui-feedback` UI context (`useUi`).
327
+ - `FormActions` deferred (depends on a not-yet-shared `core/Button`).