@dloizides/ui-forms 1.6.0 → 1.8.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,5 +1,123 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.0
4
+
5
+ **Campaign F2 — the dense CONTROLS arrive.** `@dloizides/ui-tables` shipped a complete, themed,
6
+ a11y-wired set of form controls inside its `Filters` bar and exported NONE of them: its
7
+ `src/index.ts` exposed only `Filters`, `useFilterDraft`, `suggestOptions`, `isUnmatched` and
8
+ types. Six portals could SEE those controls working and none could import them, so the fleet
9
+ reimplemented them — **6 independent select implementations, 5 date fields, 2 typeaheads**. Same
10
+ shape as `MultiTenancy.EntityFrameworkCore` shipping no EF Core. This release makes them public.
11
+
12
+ Nothing here moves a pixel for an existing consumer: the metrics are the promoted originals,
13
+ unchanged. `CONTAINER_MARGIN_BOTTOM` and `ChipSelector`'s compensating `marginBottom:
14
+ -CHIP_GUTTER` remain deliberately untouched.
15
+
16
+ - **Add `SelectControl`** — single-select enum/status dropdown. Bordered trigger + chevron opening
17
+ the shared `AnchoredMenu`.
18
+ - **Add `TypeaheadControl`** — search-as-you-type combobox (aml-v2's CountryPicker, generalized).
19
+ Free typing is preserved (the value is raw text the caller normalises at query time); picking a
20
+ suggestion fills the canonical LABEL, not the value.
21
+ - **Add `DateRangeControl`** — inclusive from/to pair. Editing one side patches ONLY that side.
22
+ - **Add `AnchoredMenu`** — the floating option list both of the above compose. Kept in-tree with
23
+ NO `ui-layout` dependency; that was a considered choice in `ui-tables` and it still holds, since
24
+ depending on the higher-level `ModalDropdown` would invert the graph for every consumer.
25
+ - **Add `controlStyles`** — the tuned metrics, promoted verbatim. `ui-tables`' `Filters/styles.ts`
26
+ said the quiet part out loud in its own header: it "reconcile[d] the per-field styles that were
27
+ copy-pasted across aml-v2's filter files into one shared source" — and then locked the
28
+ reconciliation inside one bar where no other surface could reach it.
29
+ - **Add `suggestOptions` / `isUnmatched`** — the ranked substring matcher behind the typeahead.
30
+ - **Add `ControlOption` / `DateRangeValue`** types. `ui-tables` now ALIASES its long-standing
31
+ public `FilterOption` / `DateRangeValue` onto these, so there is one definition rather than two
32
+ structurally-compatible twins free to drift.
33
+
34
+ ### The controls are LABEL-FREE, on purpose
35
+
36
+ They render the control only. Compose them inside `Field` — with `labelVariant="control"` on a
37
+ dense surface — to get the label, hint and error slots. That is what retires `ui-tables`' private
38
+ `FieldShell` fork (see `@dloizides/ui-tables` 1.14.0) and leaves ONE label implementation for
39
+ every control instead of one per control.
40
+
41
+ ### i18n: this package still never calls `t`
42
+
43
+ Every string — `accessibilityLabel`, `placeholder`, `optionHint`, `error`, `fromLabel`/`toLabel`
44
+ — is a PRE-LOCALIZED prop. This is load-bearing, not stylistic: aml-v2 localizes through
45
+ `@dloizides/i18n` with positional `{0}` while the other six portals use i18next `{{p1}}`, so a
46
+ component that localized internally could not serve both. `SelectControl`'s `accessibilityLabel`
47
+ is therefore composed BY THE CALLER — it must carry the selection ("Status: Active", not
48
+ "Status"), and building that string needs a `t` call this package must not make.
49
+
50
+ ### Fixed — a WCAG defect carried in by the promoted code
51
+
52
+ `AnchoredMenu` marked the current option with `accessibilityState={{ selected }}` alone.
53
+ **react-native-web does not read `accessibilityState` at all** (verified against
54
+ `react-native-web@0.21`'s `createDOMProps`, which enumerates `aria-selected` /
55
+ `accessibilitySelected` and never `accessibilityState`), so on web the selected option was
56
+ distinguished by colour and font-weight ONLY — no screen reader could tell which option was
57
+ current (WCAG 1.4.1, use of colour). `aria-selected` is now passed alongside, so each platform
58
+ gets the channel it honours. The trigger already paired `aria-expanded` with `accessibilityState`;
59
+ the option row was simply missed when that was done. A test asserts it, and that test FAILED
60
+ before the fix.
61
+
62
+ ## 1.7.0
63
+
64
+ Additive wave implementing the shared cross-stack forms contract now written down at
65
+ `PROOViD/AMLService/AMLService/wwwroot/shared/FORMS.md`. **Nothing in this release moves a pixel
66
+ for an existing consumer**: every addition is opt-in and every default is the previous behaviour.
67
+ `CONTAINER_MARGIN_BOTTOM` and `ChipSelector`'s compensating `marginBottom: -CHIP_GUTTER` are
68
+ deliberately untouched — they are load-bearing and move together in a later, visual-QA-gated wave.
69
+
70
+ - **Add `FormActions` — the submit/cancel row.** Promoted from the BYTE-IDENTICAL
71
+ `erevna-web/src/components/Forms/FormActions.tsx` and `katalogos-web/.../FormActions.tsx`
72
+ (68 lines each; `diff` returns nothing). All seven portals hand-roll this row; aml-v2, agora,
73
+ kefi, ichnos and zygos each inline their own variant. The primary action is LAST in DOM order so
74
+ tab reaches Cancel before Save, and `saving` drives the primary button's spinner rather than
75
+ disabling-and-relabelling it. `saveDisabled` blocks Save WITHOUT blocking Cancel — an invalid
76
+ form stays escapable. testIDs default to the twins' `save-button` / `cancel-button`, so existing
77
+ Playwright selectors keep matching.
78
+ - **`Field` gains `hint`.** ~21 hand-rolled copies of `{fontSize:12, marginTop:4, color:
79
+ textSecondary}` exist across the fleet; aml-v2 alone declares a local `hint` style in 8 files.
80
+ Because `Field` had no hint slot, consumers rendered the hint as a SIBLING, where it lands past
81
+ the container's 16px bottom margin and detaches from the control it explains (see the apologetic
82
+ comment in `aml-v2/src/screens/screening/CoverageSelector.tsx`). The hint renders UNDER the
83
+ control and ABOVE the error, and hint and error COEXIST — both are reachable from the control via
84
+ a composed `aria-describedby` (hint id then error id, in reading order). Only the error is a
85
+ `role="alert"` live region; a hint that announced itself would interrupt on every focus.
86
+ - **`Field` gains `optional` + `optionalLabel`.** Mutually exclusive with `required`; when a caller
87
+ sets both, `required` WINS — the safe direction, since an under-marked required field sends the
88
+ user into a failed submit. Enforced at runtime in `resolveFieldMarker` rather than in the type
89
+ system, so a caller spreading a computed props object still gets a defined single-marker result
90
+ instead of a compile error it cannot act on. Like the asterisk, the optional text is decorative
91
+ (`aria-hidden`) — "optional" is the ABSENCE of `aria-required`, which assistive tech already
92
+ conveys.
93
+ - **`Field` gains `labelVariant` (+ `labelStyle` as the escape hatch).** `Field` hard-coded
94
+ 13/600/sentence-case, which is exactly why kefi-web refused it and why `@dloizides/ui-tables`'
95
+ private `FieldShell` forked to 11/700/uppercase. `labelVariant` makes the contract's TWO
96
+ legitimate voices first-class and named: `field` (13/600 sentence case — a label that is READ)
97
+ and `control` (11/700 UPPERCASE + letter-spacing — a label that is SCANNED, for dense filter bars
98
+ and toolbars). An absent variant resolves to `field`, so every existing consumer renders
99
+ byte-identically. `labelStyle` applies AFTER the variant for the genuine one-off.
100
+ - **`Field`'s `containerStyle` widens `ViewStyle` -> `StyleProp<ViewStyle>`.** The narrow type was a
101
+ documented pain: `aml-v2/src/screens/leaders/CountryPicker.tsx` has to build a COMPLETE style per
102
+ variant because it cannot compose an array.
103
+ - **`useThemedInput`: a focused errored field now keeps a RED focus ring.** It previously computed
104
+ `isFocused && !hasError`, so an errored field got NO focus ring at all — removing the focus
105
+ indicator (WCAG 2.4.7) from precisely the field the user was sent back to fix, at the moment they
106
+ are most likely navigating by keyboard. It now always rings on focus and only changes the ring's
107
+ COLOUR, matching v1's `input.invalid:focus { box-shadow: 0 0 0 3px var(--danger-tint) }`.
108
+ One pre-existing test necessarily changed: it asserted `boxShadow === 'none'` for this state,
109
+ i.e. it encoded the defect as intended behaviour.
110
+ - **New peer dependency `@dloizides/ui-buttons >= 1.4.0`**, required by `FormActions` so the estate
111
+ keeps exactly one button implementation rather than forking one here.
112
+ - No i18n runtime is introduced: all user-visible copy (`saveLabel`, `cancelLabel`, `optionalLabel`,
113
+ `hint`, `error`) arrives pre-localized. aml-v2 localizes via `@dloizides/i18n` with positional
114
+ `{0}` params while the other six apps use i18next `{{p1}}`, and nothing bridges them, so a
115
+ component that localized internally could not serve both.
116
+ - Also shipping in this tarball, from a CONCURRENT workstream (commit `13bd281`, not part of this
117
+ wave): `ChipSelector` gains `chipTestIDPrefix`, `optionAccessibilityHint` and `multiple`
118
+ toggle semantics, and reads its on-primary ink from the theme's `onBrand` scale instead of a
119
+ hardcoded `#fff`. All opt-in; the `chip-selector-chip-<value>` testID default is frozen.
120
+
3
121
  ## 1.6.0
4
122
 
5
123
  - **`ChipSelector` now composes `Field` internally — its label was drifting.** It rendered its OWN
package/README.md CHANGED
@@ -98,6 +98,48 @@ with no label no label row is rendered, so it drops into a toolbar or filter bar
98
98
  | `containerStyle` | `ViewStyle` | merged over the container |
99
99
  | `testID` | `string` | on the container |
100
100
 
101
+ ## Dense controls — `SelectControl` / `TypeaheadControl` / `DateRangeControl`
102
+
103
+ Promoted in 1.8.0 out of `@dloizides/ui-tables`' private filter bar, where they worked correctly
104
+ for six portals and were exported to none — which is why the fleet grew 6 selects, 5 date fields
105
+ and 2 typeaheads.
106
+
107
+ They are **label-free by design**. Compose them inside `Field` so there is ONE label
108
+ implementation for every control rather than one per control:
109
+
110
+ ```tsx
111
+ <Field label="Status" labelVariant="control" containerStyle={{ marginBottom: 0 }}>
112
+ <SelectControl
113
+ accessibilityLabel={t('filters.statusName', 'Status', selectedLabel)}
114
+ options={options}
115
+ testID="filters-status"
116
+ value={value}
117
+ onChange={setValue}
118
+ />
119
+ </Field>
120
+ ```
121
+
122
+ `labelVariant="control"` is the 11/700/uppercase voice from
123
+ [`FORMS.md`](../../../PROOViD/AMLService/AMLService/wwwroot/shared/FORMS.md) — the voice for
124
+ labels that are SCANNED (filter bars, toolbars) rather than READ.
125
+
126
+ ### Every string is pre-localized
127
+
128
+ This package never calls `t`. In particular `SelectControl`'s `accessibilityLabel` must be
129
+ composed by YOU and must carry the **selection** — "Status: Active", not "Status". The accessible
130
+ name REPLACES the trigger's visible text for a screen reader, so naming it with the field alone
131
+ silently hides what is selected.
132
+
133
+ | Control | Key props | testIDs |
134
+ |---|---|---|
135
+ | `SelectControl` | `options`, `value`, `onChange`, `placeholder`, `accessibilityLabel`, `optionHint` | `<testID>-trigger`, `-menu`, `-option-<value>` |
136
+ | `TypeaheadControl` | `options`, `value`, `onChange`, `onSubmit`, `error`, `minChars`, `maxSuggestions` | `<testID>-input`, `-menu`, `-error` |
137
+ | `DateRangeControl` | `value` (`{from,to}`), `onChange`, `onSubmit`, `fromLabel`, `toLabel` | `<testID>-range`, `-from`, `-to` |
138
+ | `AnchoredMenu` | the floating list the first two compose | `<testID>-menu` |
139
+
140
+ Also exported: `controlStyles` (the tuned metrics), `suggestOptions` / `isUnmatched` (the
141
+ typeahead's ranker), and the `ControlOption` / `DateRangeValue` types.
142
+
101
143
  ## testIDs
102
144
 
103
145
  `form-field-input`, `chip-selector-chip-<value>`, `form-switch` (overridable on FormSwitch).
package/dist/index.d.mts CHANGED
@@ -1,8 +1,31 @@
1
1
  import React from 'react';
2
- import { ViewStyle, TextInputProps, TextInput, TextStyle } from 'react-native';
2
+ import { StyleProp, TextStyle, ViewStyle, TextInputProps, TextInput, View } from 'react-native';
3
3
 
4
4
  /**
5
- * Field — the generic "label over a control, error under it" wrapper.
5
+ * The TWO legitimate label voices, per the shared forms contract
6
+ * (`PROOViD/AMLService/AMLService/wwwroot/shared/FORMS.md` → "The TWO legitimate label voices").
7
+ *
8
+ * - `field` — 13 / 600 / sentence case. A label that is READ: prose addressed to the person
9
+ * filling the form. This is `Field`'s historical, unchanged default, so every
10
+ * existing consumer renders byte-identically.
11
+ * - `control` — 11 / 700 / UPPERCASE + letter-spacing. A label that is SCANNED: a key over a value
12
+ * in a dense row (filter bars, compact toolbars, pager controls). These are exactly
13
+ * the metrics `@dloizides/ui-tables` forked into its private `FieldShell` because
14
+ * `Field` hard-coded the `field` voice and offered no way out.
15
+ *
16
+ * A THIRD voice is drift, not a variant. Pick the voice from the SURFACE, not the component: every
17
+ * label on one surface uses one voice, and voices are never mixed within a single row.
18
+ *
19
+ * Why a plain string union and not a `const enum`: this type crosses the package boundary into
20
+ * consumers' `.d.ts`, and a cross-boundary `const enum` breaks `isolatedModules` / Babel-only
21
+ * builds. `@dloizides/ui-buttons` sets the same precedent for `ButtonVariant` for the same reason.
22
+ */
23
+
24
+ /** Which of the two contract voices a field label speaks. */
25
+ type FieldLabelVariant = 'field' | 'control';
26
+
27
+ /**
28
+ * Field — the generic "label over a control, hint + error under it" wrapper.
6
29
  *
7
30
  * `FormField` is hard-wired to a text input, so any NON-text control (a dropdown, a date picker,
8
31
  * a chip selector) had no shared way to get a label — and ended up visually misaligned next to
@@ -13,13 +36,20 @@ import { ViewStyle, TextInputProps, TextInput, TextStyle } from 'react-native';
13
36
  * truth: `FormField` composes `Field` internally, so the two can never drift apart.
14
37
  *
15
38
  * Children may be a plain node (the common case — just wrap your control) or a render function
16
- * receiving `{ describedById, hasError }`, so a custom control can wire the error line into its
17
- * own `aria-describedby` / invalid state exactly the way `FormField` does.
39
+ * receiving `{ describedById, hasError }`, so a custom control can wire the hint AND error lines
40
+ * into its own `aria-describedby` / invalid state exactly the way `FormField` does.
41
+ *
42
+ * The anatomy (label → control → hint → error), the two label voices, the mutually-exclusive
43
+ * required/optional markers and the collapsing error slot all implement the shared cross-stack
44
+ * contract in `PROOViD/AMLService/AMLService/wwwroot/shared/FORMS.md`.
18
45
  */
19
46
 
20
47
  /** What a render-function child receives, so it can mirror `FormField`'s error wiring. */
21
48
  interface FieldChildContext {
22
- /** id of the rendered error line, or `undefined` when there is no error. */
49
+ /**
50
+ * Space-separated ids of the lines describing this control — the hint, the error, or both, in
51
+ * reading order. `undefined` when there is neither. Assign straight to `aria-describedby`.
52
+ */
23
53
  describedById?: string;
24
54
  hasError: boolean;
25
55
  }
@@ -34,11 +64,123 @@ interface FieldProps {
34
64
  label?: string;
35
65
  children: FieldChildren;
36
66
  required?: boolean;
67
+ /**
68
+ * Marks the field explicitly optional. MUTUALLY EXCLUSIVE with `required` — if both are set,
69
+ * `required` wins (the safe direction: an under-marked required field causes a failed submit).
70
+ * Use it to mark the MINORITY: on a mostly-required form, marking the two optional fields is far
71
+ * less noise than asterisking the other eleven.
72
+ */
73
+ optional?: boolean;
74
+ /**
75
+ * Pre-localized text for the optional marker. Defaults to the English `DEFAULT_OPTIONAL_LABEL`
76
+ * so a single-locale app can adopt `optional` in one line; a localized app MUST pass this.
77
+ */
78
+ optionalLabel?: string;
79
+ /**
80
+ * Help line rendered UNDER the control and ABOVE the error — the sentence that stops the user
81
+ * guessing. It coexists with `error` (the hint states the rule, the error says which rule was
82
+ * broken) and both are reachable from the control via `aria-describedby`.
83
+ *
84
+ * Rendering a hint INSIDE the field is the point: as an outside sibling it lands past the
85
+ * container's 16px bottom margin and detaches from the control it explains.
86
+ */
87
+ hint?: string;
37
88
  error?: string;
38
- containerStyle?: ViewStyle;
89
+ /**
90
+ * Which of the two contract label voices to speak: `field` (13/600 sentence case — the default,
91
+ * unchanged) or `control` (11/700 uppercase — dense filter bars and toolbars).
92
+ */
93
+ labelVariant?: FieldLabelVariant;
94
+ /**
95
+ * Escape hatch applied AFTER `labelVariant`, for the genuine one-off a variant does not cover.
96
+ * Prefer `labelVariant`: it is the reviewable, named choice, and a bare `labelStyle` is the thing
97
+ * to grep for when auditing label drift.
98
+ */
99
+ labelStyle?: StyleProp<TextStyle>;
100
+ /**
101
+ * `StyleProp` (not a bare `ViewStyle`) so a caller can compose an array — e.g. a base field style
102
+ * plus a `grow` modifier — instead of having to restate a COMPLETE style per variant.
103
+ */
104
+ containerStyle?: StyleProp<ViewStyle>;
105
+ testID?: string;
106
+ }
107
+ declare const Field: ({ label, children, required, optional, optionalLabel, hint, error, labelVariant, labelStyle, containerStyle, testID, }: FieldProps) => React.ReactElement;
108
+
109
+ /**
110
+ * Required / optional markers, per the shared forms contract
111
+ * (`…/wwwroot/shared/FORMS.md` → "Required and optional markers").
112
+ *
113
+ * The contract says the two markers are MUTUALLY EXCLUSIVE — a field is required or optional, never
114
+ * annotated as both — and that you mark the MINORITY: on a mostly-required form mark the optional
115
+ * fields, on a mostly-optional form mark the required ones. Asterisking twelve of thirteen fields
116
+ * is noise that trains the eye to ignore the marker.
117
+ *
118
+ * Exclusivity is enforced here rather than in the type system so that a caller spreading a props
119
+ * object (`<Field {...fieldProps} />`, where both flags may be computed) still gets a defined,
120
+ * single-marker result instead of a compile error it cannot act on.
121
+ *
122
+ * Precedence when a caller asks for BOTH: `required` wins. That is the safe failure direction —
123
+ * showing "optional" on a field the server will reject sends the user into a failed submit, while
124
+ * showing "required" on a genuinely optional field costs, at worst, an unnecessary keystroke.
125
+ */
126
+ /** Which marker a field label carries. Exactly one, never both. */
127
+ type FieldMarker = 'none' | 'required' | 'optional';
128
+ /**
129
+ * The English fallback for the optional marker's text.
130
+ *
131
+ * `@dloizides/ui-forms` contains NO i18n runtime by design — aml-v2 uses `@dloizides/i18n` with
132
+ * positional `{0}` params while the other six apps use i18next `{{p1}}`, and nothing bridges them,
133
+ * so all user-visible copy arrives as pre-localized props. This constant exists ONLY so a
134
+ * single-locale app can adopt `optional` in one line. It is exported precisely so it is greppable:
135
+ * **a localized app MUST pass `optionalLabel`.** The required marker needs no such escape — `*` is
136
+ * a glyph, not copy.
137
+ */
138
+ declare const DEFAULT_OPTIONAL_LABEL = "(optional)";
139
+
140
+ /**
141
+ * `FormActions` — the submit/cancel button row every form ends with.
142
+ *
143
+ * Promoted from the BYTE-IDENTICAL `erevna-web/src/components/Forms/FormActions.tsx` and
144
+ * `katalogos-web/src/components/Forms/FormActions.tsx` (68 lines each, `diff` returns nothing);
145
+ * all seven apps hand-roll the same row. Buttons come from the shared `@dloizides/ui-buttons` kit
146
+ * rather than being forked here, so there is exactly one button implementation in the estate.
147
+ *
148
+ * The layout implements the contract's actions row
149
+ * (`…/wwwroot/shared/FORMS.md` → "Actions row"): the primary action is LAST in DOM order, so tab
150
+ * order reaches Cancel before Save, and `saving` drives the primary button's spinner rather than
151
+ * disabling-and-relabelling it.
152
+ *
153
+ * Two minimal generalizations over the app-local twins:
154
+ * 1. Labels and hints arrive as PRE-LOCALIZED props. The twins called `FM()` internally, which
155
+ * cannot work here: aml-v2 localizes via `@dloizides/i18n` with positional `{0}` params while
156
+ * the other six use i18next `{{p1}}`, and nothing bridges them.
157
+ * 2. `testID`s are overridable but default to the twins' `save-button` / `cancel-button`, so the
158
+ * existing Playwright selectors in every app keep matching.
159
+ */
160
+
161
+ interface FormActionsProps {
162
+ /** Submit handler. May be async — the shared Button drives its own spinner for the promise. */
163
+ onSave: () => void | Promise<unknown>;
164
+ /** Cancel handler. Omit it entirely to render a save-only row (no empty Cancel slot). */
165
+ onCancel?: () => void | Promise<unknown>;
166
+ /** Pre-localized label for the primary action. */
167
+ saveLabel: string;
168
+ /** Pre-localized label for the cancel action. Required whenever `onCancel` is supplied. */
169
+ cancelLabel?: string;
170
+ /** Pre-localized accessibility hint for the primary action. Falls back to its label. */
171
+ saveHint?: string;
172
+ /** Pre-localized accessibility hint for the cancel action. Falls back to its label. */
173
+ cancelHint?: string;
174
+ /** In-flight submit: spins the primary action and disables cancel so the form can't be abandoned mid-write. */
175
+ saving?: boolean;
176
+ /** Disables the primary action independently of `saving` (e.g. an invalid form). */
177
+ saveDisabled?: boolean;
178
+ containerStyle?: StyleProp<ViewStyle>;
179
+ saveTestID?: string;
180
+ cancelTestID?: string;
39
181
  testID?: string;
40
182
  }
41
- declare const Field: ({ label, children, required, error, containerStyle, testID, }: FieldProps) => React.ReactElement;
183
+ declare const FormActions: ({ onSave, onCancel, saveLabel, cancelLabel, saveHint, cancelHint, saving, saveDisabled, containerStyle, saveTestID, cancelTestID, testID, }: FormActionsProps) => React.ReactElement;
42
184
 
43
185
  /**
44
186
  * Reusable form field — labelled text input with optional required mark + error.
@@ -153,8 +295,20 @@ interface ChipSelectorProps<T> {
153
295
  /** Validation message under the chips, wired to the chip group via `aria-describedby`. */
154
296
  error?: string;
155
297
  testID?: string;
298
+ /**
299
+ * Stem for each chip's testID — a chip becomes `${chipTestIDPrefix}-${value}`. Defaults to
300
+ * `chip-selector-chip`, which existing specs select on; supply your own when a screen has
301
+ * several chip groups that must be addressed independently.
302
+ */
303
+ chipTestIDPrefix?: string;
304
+ /**
305
+ * Accessibility hint applied to every chip in the group. Supply a LOCALIZED string — the
306
+ * built-in default (`Selects <label>`) is English-only and exists solely so pre-existing
307
+ * consumers keep their current announcement.
308
+ */
309
+ optionAccessibilityHint?: string;
156
310
  }
157
- declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, required, error, testID, }: ChipSelectorProps<T>) => React.ReactElement;
311
+ declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, required, error, testID, chipTestIDPrefix, optionAccessibilityHint, }: ChipSelectorProps<T>) => React.ReactElement;
158
312
 
159
313
  /**
160
314
  * Reusable form switch/toggle with an optional description.
@@ -197,4 +351,277 @@ interface FormCheckboxProps {
197
351
  }
198
352
  declare const FormCheckbox: ({ label, value, onValueChange, onChange, hint, description, disabled, containerStyle, testID, accessibilityLabel, accessibilityHint, }: FormCheckboxProps) => React.ReactElement;
199
353
 
200
- export { type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, Field, type FieldChildContext, type FieldChildren, type FieldProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormSwitch, type FormSwitchProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, type UseThemedInputOptions, useThemedInput };
354
+ /**
355
+ * Shared value types for the dense CONTROLS (select / typeahead / date-range) promoted out of
356
+ * `@dloizides/ui-tables`' private filter bar.
357
+ *
358
+ * They live here, not in `ui-tables`, because a control's option list and a date range are form
359
+ * concepts — a filter bar is only one surface that happens to compose them. `ui-tables` aliases
360
+ * its long-standing public names (`FilterOption`, `DateRangeValue`) onto these, so there is ONE
361
+ * definition and the two can never drift into structurally-compatible-but-separate twins.
362
+ */
363
+ /** A single choice in a select / typeahead. `label` is PRE-LOCALIZED by the caller. */
364
+ interface ControlOption {
365
+ readonly label: string;
366
+ readonly value: string;
367
+ }
368
+ /** The inclusive from/to value of a date-range control (each an ISO `YYYY-MM-DD` string, or ''). */
369
+ interface DateRangeValue {
370
+ readonly from: string;
371
+ readonly to: string;
372
+ }
373
+
374
+ /**
375
+ * `SelectControl` — a single-select enum/status dropdown. A bordered trigger shows the selected
376
+ * option's label (or the placeholder when unset) plus a chevron; pressing it opens the shared
377
+ * {@link AnchoredMenu}. Selecting fires `onChange` and closes.
378
+ *
379
+ * Promoted out of `@dloizides/ui-tables`' private `Filters/fields/SelectField`, where six portals
380
+ * could see it working and none could import it — which is why the fleet grew SIX independent
381
+ * select implementations.
382
+ *
383
+ * The control is LABEL-FREE by design: wrap it in a `Field` (with `labelVariant="control"` on a
384
+ * dense surface) to get the label, hint and error slots. That keeps one label implementation for
385
+ * every control instead of one per control.
386
+ *
387
+ * i18n: this package never calls `t`. `accessibilityLabel`, `placeholder`, `accessibilityHint`
388
+ * and `optionHint` are PRE-LOCALIZED strings supplied by the caller — the only shape that can
389
+ * serve both aml-v2's positional `{0}` `@dloizides/i18n` and the other six portals' i18next
390
+ * `{{p1}}` from one component.
391
+ */
392
+
393
+ interface SelectControlProps {
394
+ options: readonly ControlOption[];
395
+ /** The selected option's `value`. `''` (or any unmatched value) renders the placeholder. */
396
+ value: string;
397
+ onChange: (value: string) => void;
398
+ /** PRE-LOCALIZED text shown when the value matches no option. */
399
+ placeholder?: string;
400
+ /**
401
+ * PRE-LOCALIZED accessible NAME of the trigger. It REPLACES the trigger's visible text for a
402
+ * screen reader, so it must carry the SELECTION too — "Status: Active", not just "Status",
403
+ * which announces the control but silently hides its value.
404
+ */
405
+ accessibilityLabel: string;
406
+ accessibilityHint?: string;
407
+ /** PRE-LOCALIZED accessibility hint applied to every option in the menu. */
408
+ optionHint?: string;
409
+ /** Merged LAST onto the anchor wrapper — the consumer always wins over the shared style. */
410
+ style?: StyleProp<ViewStyle>;
411
+ /** Trigger is `${testID}-trigger`, menu `${testID}-menu`, options `${testID}-option-${value}`. */
412
+ testID: string;
413
+ }
414
+ declare function SelectControl({ options, value, onChange, placeholder, accessibilityLabel, accessibilityHint, optionHint, style, testID, }: SelectControlProps): React.ReactElement;
415
+
416
+ /**
417
+ * `TypeaheadControl` — a search-as-you-type combobox (aml-v2's CountryPicker, generalized). A
418
+ * text input plus a floating {@link AnchoredMenu} of ranked suggestions ({@link suggestOptions});
419
+ * picking one fills the canonical label.
420
+ *
421
+ * Free typing is PRESERVED: the value is the raw text, so a caller can accept "CY", "cyprus" or
422
+ * "Cyprus (CY)" and normalise it themselves at query time, exactly as aml does. An optional
423
+ * PRE-LOCALIZED `error` shows a persistent banner and tints the border.
424
+ *
425
+ * Promoted out of `@dloizides/ui-tables`' private `Filters/fields/TypeaheadField`.
426
+ *
427
+ * Label-free by design — wrap in a `Field` for the label/hint slots.
428
+ */
429
+
430
+ /** Default min characters before suggestions surface. */
431
+ declare const DEFAULT_TYPEAHEAD_MIN_CHARS = 1;
432
+ /** Default cap on rendered suggestions. */
433
+ declare const DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = 8;
434
+ interface TypeaheadControlProps {
435
+ options: readonly ControlOption[];
436
+ /** The raw text in the box — NOT necessarily an option value. */
437
+ value: string;
438
+ onChange: (value: string) => void;
439
+ /** Called on Enter/submit (after the menu closes). Omit if the surface has nothing to commit. */
440
+ onSubmit?: () => void;
441
+ placeholder?: string;
442
+ /** PRE-LOCALIZED accessible name of the input. */
443
+ accessibilityLabel: string;
444
+ accessibilityHint?: string;
445
+ /** PRE-LOCALIZED accessibility hint applied to every suggestion. */
446
+ optionHint?: string;
447
+ /** PRE-LOCALIZED inline error (e.g. "Unrecognised country"). Shows a persistent banner. */
448
+ error?: string;
449
+ /** Minimum characters before suggestions surface. Default 1. */
450
+ minChars?: number;
451
+ /** Cap on rendered suggestions. Default 8. */
452
+ maxSuggestions?: number;
453
+ /** Merged LAST onto the anchor wrapper. */
454
+ style?: StyleProp<ViewStyle>;
455
+ /** Input is `${testID}-input`, menu `${testID}-menu`, error `${testID}-error`. */
456
+ testID: string;
457
+ }
458
+ declare function TypeaheadControl({ options, value, onChange, onSubmit, placeholder, accessibilityLabel, accessibilityHint, optionHint, error, minChars, maxSuggestions, style, testID, }: TypeaheadControlProps): React.ReactElement;
459
+
460
+ /**
461
+ * `DateRangeControl` — an inclusive from/to pair (aml cases `from`/`to`, kefi audit `from`/`to`).
462
+ * Each side is a `YYYY-MM-DD` text input; on web the browser's native date UI is offered via
463
+ * `type="date"` (a react-native-web `TextInput` passes unknown props to the DOM input). Editing
464
+ * either side patches ONLY that side, so the other survives the edit.
465
+ *
466
+ * Promoted out of `@dloizides/ui-tables`' private `Filters/fields/DateRangeField`, which is why
467
+ * the fleet grew FIVE independent date fields.
468
+ *
469
+ * Label-free by design — the From/To sub-labels name the two HALVES; wrap in a `Field` for the
470
+ * range's own label.
471
+ */
472
+
473
+ interface DateRangeControlProps {
474
+ value: DateRangeValue;
475
+ onChange: (value: DateRangeValue) => void;
476
+ /** Called on Enter/submit in either side. */
477
+ onSubmit?: () => void;
478
+ /** PRE-LOCALIZED sub-label for the FROM input (e.g. "From"). Also its accessible name. */
479
+ fromLabel: string;
480
+ /** PRE-LOCALIZED sub-label for the TO input (e.g. "To"). Also its accessible name. */
481
+ toLabel: string;
482
+ fromPlaceholder?: string;
483
+ toPlaceholder?: string;
484
+ /** PRE-LOCALIZED hint applied to BOTH inputs. */
485
+ accessibilityHint?: string;
486
+ /** Merged LAST onto the row wrapper. */
487
+ style?: StyleProp<ViewStyle>;
488
+ /** Row is `${testID}-range`, inputs `${testID}-from` / `${testID}-to`. */
489
+ testID: string;
490
+ }
491
+ declare function DateRangeControl({ value, onChange, onSubmit, fromLabel, toLabel, fromPlaceholder, toPlaceholder, accessibilityHint, style, testID, }: DateRangeControlProps): React.ReactElement;
492
+
493
+ /**
494
+ * `AnchoredMenu` — the floating option list shared by `SelectControl` and `TypeaheadControl`.
495
+ *
496
+ * Renders an in-tree, absolutely-positioned popover under its anchor (the pattern aml-v2's
497
+ * CountryPicker proved: `position:absolute; top:100%; zIndex; elevation`), so it floats over
498
+ * sibling fields without a portal. On web it dismisses on outside-click / Escape; on native the
499
+ * caller closes it on blur/select. Purely presentational + theme-flat, so it stays reusable.
500
+ *
501
+ * Promoted verbatim out of `@dloizides/ui-tables`' private `Filters/components/AnchoredMenu`.
502
+ *
503
+ * Deliberately in-tree (no `ui-layout` dependency). That was a considered choice in `ui-tables`
504
+ * and it still holds here: `ui-forms` is a low-level primitive and must not take a runtime
505
+ * dependency on the higher-level `ui-layout` `ModalDropdown` — that would invert the graph for
506
+ * every consumer. Apps needing the responsive modal/bottom-sheet variant inject it instead.
507
+ */
508
+
509
+ /** The four theme colours the menu paints with — passed in, so the menu stays theme-flat. */
510
+ interface AnchoredMenuColors {
511
+ text: string;
512
+ border: string;
513
+ surface: string;
514
+ brand: string;
515
+ }
516
+ interface AnchoredMenuProps {
517
+ options: readonly ControlOption[];
518
+ /** The currently-selected value, highlighted in the list ('' = none). */
519
+ selectedValue: string;
520
+ onSelect: (value: string) => void;
521
+ onDismiss: () => void;
522
+ colors: AnchoredMenuColors;
523
+ /** PRE-LOCALIZED accessibility hint applied to every option. */
524
+ optionHint: string;
525
+ /** Base testID: the menu is `${testID}-menu`, each option `${testID}-option-${value}`. */
526
+ testID: string;
527
+ /** Node whose clicks count as "inside" (the anchor) so its own press doesn't self-dismiss. */
528
+ anchorRef: React.RefObject<View | null>;
529
+ }
530
+ declare function AnchoredMenu({ options, selectedValue, onSelect, onDismiss, colors, optionHint, testID, anchorRef, }: AnchoredMenuProps): React.ReactElement;
531
+
532
+ declare const controlStyles: {
533
+ /** The shared bordered box every dense text control renders in. */
534
+ input: {
535
+ borderWidth: number;
536
+ borderRadius: number;
537
+ paddingHorizontal: number;
538
+ paddingVertical: number;
539
+ fontSize: number;
540
+ };
541
+ /** The select trigger: a bordered field box matching the text inputs + a chevron. */
542
+ selectTrigger: {
543
+ flexDirection: "row";
544
+ alignItems: "center";
545
+ justifyContent: "space-between";
546
+ gap: number;
547
+ borderWidth: number;
548
+ borderRadius: number;
549
+ paddingHorizontal: number;
550
+ paddingVertical: number;
551
+ };
552
+ selectTriggerText: {
553
+ fontSize: number;
554
+ flexShrink: number;
555
+ };
556
+ chevron: {
557
+ fontSize: number;
558
+ };
559
+ dateRow: {
560
+ flexDirection: "row";
561
+ gap: number;
562
+ };
563
+ dateCol: {
564
+ gap: number;
565
+ flex: number;
566
+ };
567
+ /**
568
+ * The micro-caption above a sub-control (a date range's From/To) and beside a switch. Note it
569
+ * is deliberately NOT the `control` label voice: it carries no `700`/uppercase, because it
570
+ * names a HALF of a control that already has a `control`-voice label above it. Uppercasing it
571
+ * too would make one field shout twice.
572
+ */
573
+ subLabel: {
574
+ fontSize: number;
575
+ letterSpacing: number;
576
+ };
577
+ /** Positioning context for a floating `AnchoredMenu` (`position:absolute; top:100%`). */
578
+ anchor: {
579
+ position: "relative";
580
+ };
581
+ menu: {
582
+ position: "absolute";
583
+ top: "100%";
584
+ left: number;
585
+ marginTop: number;
586
+ minWidth: number;
587
+ maxHeight: number;
588
+ borderWidth: number;
589
+ borderRadius: number;
590
+ paddingVertical: number;
591
+ zIndex: number;
592
+ boxShadow: string;
593
+ elevation: number;
594
+ overflow: "hidden";
595
+ };
596
+ option: {
597
+ paddingHorizontal: number;
598
+ paddingVertical: number;
599
+ };
600
+ optionText: {
601
+ fontSize: number;
602
+ };
603
+ error: {
604
+ fontSize: number;
605
+ marginTop: number;
606
+ };
607
+ };
608
+
609
+ /**
610
+ * `suggestOptions` — the ranked substring matcher behind `TypeaheadControl`, generalized from
611
+ * aml-v2's `suggestCountries` and promoted verbatim out of `@dloizides/ui-tables`' private
612
+ * `Filters/utils/suggest.ts`.
613
+ *
614
+ * Ranks: value exact (0) > value prefix (1) > label prefix (2) > label contains (3); ties broken
615
+ * by `label.localeCompare`. Empty/short queries return `[]` — nothing surfaces until `minChars`
616
+ * is typed, mirroring a native `<datalist>`.
617
+ */
618
+
619
+ /**
620
+ * Rank + cap the options matching `query`. Returns `[]` until `query` (trimmed) is at least
621
+ * `minChars` long, then the best `limit` matches, ranked as documented above.
622
+ */
623
+ declare function suggestOptions(options: readonly ControlOption[], query: string, minChars: number, limit: number): readonly ControlOption[];
624
+ /** True when `text` is non-empty yet matches no option's value or label exactly (case-insensitive). */
625
+ declare function isUnmatched(options: readonly ControlOption[], text: string): boolean;
626
+
627
+ export { AnchoredMenu, type AnchoredMenuColors, type AnchoredMenuProps, type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, type ControlOption, DEFAULT_OPTIONAL_LABEL, DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS, DEFAULT_TYPEAHEAD_MIN_CHARS, DateRangeControl, type DateRangeControlProps, type DateRangeValue, Field, type FieldChildContext, type FieldChildren, type FieldLabelVariant, type FieldMarker, type FieldProps, FormActions, type FormActionsProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormSwitch, type FormSwitchProps, SelectControl, type SelectControlProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, TypeaheadControl, type TypeaheadControlProps, type UseThemedInputOptions, controlStyles, isUnmatched, suggestOptions, useThemedInput };