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