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