@dloizides/ui-forms 1.9.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,280 +1,280 @@
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.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`).