@dloizides/ui-forms 1.5.0 → 1.7.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/dist/index.d.ts 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 } 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,26 +36,151 @@ 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
  }
26
56
  type FieldChildren = React.ReactNode | ((context: FieldChildContext) => React.ReactNode);
27
57
  interface FieldProps {
28
- label: string;
58
+ /**
59
+ * Label shown above the control. Optional so a control whose label is supplied elsewhere (or
60
+ * which genuinely has none) can still use the shared shell for its error line + spacing: an
61
+ * absent or empty label renders NO label row at all, rather than an empty one that would add a
62
+ * phantom gap and misalign the control against its labelled siblings.
63
+ */
64
+ label?: string;
29
65
  children: FieldChildren;
30
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;
31
88
  error?: string;
32
- 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>;
33
105
  testID?: string;
34
106
  }
35
- declare const Field: ({ label, children, required, error, containerStyle, testID, }: FieldProps) => React.ReactElement;
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;
181
+ testID?: string;
182
+ }
183
+ declare const FormActions: ({ onSave, onCancel, saveLabel, cancelLabel, saveHint, cancelHint, saving, saveDisabled, containerStyle, saveTestID, cancelTestID, testID, }: FormActionsProps) => React.ReactElement;
36
184
 
37
185
  /**
38
186
  * Reusable form field — labelled text input with optional required mark + error.
@@ -117,6 +265,12 @@ declare function useThemedInput(options?: UseThemedInputOptions): ThemedInput;
117
265
  * - `outline` — the AML v1 console look: an outlined pill on a muted fill; selected = a subtle
118
266
  * tinted-outline (primary border + primary ink + a low-alpha primary wash), NOT a solid fill.
119
267
  * Hover (web) lifts the border/ink to the primary colour.
268
+ *
269
+ * The label row, the error line and the container spacing come from the shared `Field` wrapper —
270
+ * NOT from a local `<Text>`. Its own label used to drift from `Field` on three of four axes
271
+ * (`marginBottom: 8` vs 4, no `fontSize` at all so ~14 vs 13, `colors.text` vs `textSecondary`),
272
+ * so a `ChipSelector` next to a `FormField` in a row/grid started a different distance down the
273
+ * column and read as a different weight of text. Now there is exactly one label implementation.
120
274
  */
121
275
 
122
276
  /** Visual variant. `solid` = filled selected pill (default); `outline` = v1 tinted-outline. */
@@ -126,6 +280,7 @@ interface ChipOption<T> {
126
280
  label: string;
127
281
  }
128
282
  interface ChipSelectorProps<T> {
283
+ /** Optional — with no label, no label row is rendered (no phantom gap above the chips). */
129
284
  label?: string;
130
285
  options: Array<ChipOption<T>>;
131
286
  value: T | T[];
@@ -135,8 +290,25 @@ interface ChipSelectorProps<T> {
135
290
  /** Visual variant. `solid` (default) = filled selected pill; `outline` = v1 tinted-outline. */
136
291
  variant?: ChipVariant;
137
292
  containerStyle?: ViewStyle;
293
+ /** Marks the selection mandatory — renders `Field`'s decorative `*` next to the label. */
294
+ required?: boolean;
295
+ /** Validation message under the chips, wired to the chip group via `aria-describedby`. */
296
+ error?: string;
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;
138
310
  }
139
- declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, }: 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;
140
312
 
141
313
  /**
142
314
  * Reusable form switch/toggle with an optional description.
@@ -179,4 +351,4 @@ interface FormCheckboxProps {
179
351
  }
180
352
  declare const FormCheckbox: ({ label, value, onValueChange, onChange, hint, description, disabled, containerStyle, testID, accessibilityLabel, accessibilityHint, }: FormCheckboxProps) => React.ReactElement;
181
353
 
182
- 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
+ export { type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, DEFAULT_OPTIONAL_LABEL, Field, type FieldChildContext, type FieldChildren, type FieldLabelVariant, type FieldMarker, type FieldProps, FormActions, type FormActionsProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormSwitch, type FormSwitchProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, type UseThemedInputOptions, useThemedInput };