@stapel/forms-react 0.1.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.
Files changed (203) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/MODULE.md +172 -0
  3. package/README.md +207 -0
  4. package/dist/api/export.d.ts +66 -0
  5. package/dist/api/export.d.ts.map +1 -0
  6. package/dist/api/export.js +138 -0
  7. package/dist/api/export.js.map +1 -0
  8. package/dist/api/extensions.d.ts +8 -0
  9. package/dist/api/extensions.d.ts.map +1 -0
  10. package/dist/api/extensions.js +8 -0
  11. package/dist/api/extensions.js.map +1 -0
  12. package/dist/api/formsApi.d.ts +113 -0
  13. package/dist/api/formsApi.d.ts.map +1 -0
  14. package/dist/api/formsApi.js +81 -0
  15. package/dist/api/formsApi.js.map +1 -0
  16. package/dist/api/generated/schema.d.ts +987 -0
  17. package/dist/api/generated/schema.d.ts.map +1 -0
  18. package/dist/api/generated/schema.js +2 -0
  19. package/dist/api/generated/schema.js.map +1 -0
  20. package/dist/api/types.d.ts +222 -0
  21. package/dist/api/types.d.ts.map +1 -0
  22. package/dist/api/types.js +14 -0
  23. package/dist/api/types.js.map +1 -0
  24. package/dist/default/ConfigField.d.ts +32 -0
  25. package/dist/default/ConfigField.d.ts.map +1 -0
  26. package/dist/default/ConfigField.js +101 -0
  27. package/dist/default/ConfigField.js.map +1 -0
  28. package/dist/default/ErrorAlert.d.ts +9 -0
  29. package/dist/default/ErrorAlert.d.ts.map +1 -0
  30. package/dist/default/ErrorAlert.js +27 -0
  31. package/dist/default/ErrorAlert.js.map +1 -0
  32. package/dist/default/FormBuilderPane.d.ts +36 -0
  33. package/dist/default/FormBuilderPane.d.ts.map +1 -0
  34. package/dist/default/FormBuilderPane.js +70 -0
  35. package/dist/default/FormBuilderPane.js.map +1 -0
  36. package/dist/default/FormsListPane.d.ts +18 -0
  37. package/dist/default/FormsListPane.d.ts.map +1 -0
  38. package/dist/default/FormsListPane.js +28 -0
  39. package/dist/default/FormsListPane.js.map +1 -0
  40. package/dist/default/ResponsesPane.d.ts +21 -0
  41. package/dist/default/ResponsesPane.d.ts.map +1 -0
  42. package/dist/default/ResponsesPane.js +118 -0
  43. package/dist/default/ResponsesPane.js.map +1 -0
  44. package/dist/default/StapelForm.d.ts +76 -0
  45. package/dist/default/StapelForm.d.ts.map +1 -0
  46. package/dist/default/StapelForm.js +118 -0
  47. package/dist/default/StapelForm.js.map +1 -0
  48. package/dist/default/fields.d.ts +11 -0
  49. package/dist/default/fields.d.ts.map +1 -0
  50. package/dist/default/fields.js +273 -0
  51. package/dist/default/fields.js.map +1 -0
  52. package/dist/default/index.d.ts +42 -0
  53. package/dist/default/index.d.ts.map +1 -0
  54. package/dist/default/index.js +37 -0
  55. package/dist/default/index.js.map +1 -0
  56. package/dist/default/slots.d.ts +59 -0
  57. package/dist/default/slots.d.ts.map +1 -0
  58. package/dist/default/slots.js +25 -0
  59. package/dist/default/slots.js.map +1 -0
  60. package/dist/default/theme.d.ts +23 -0
  61. package/dist/default/theme.d.ts.map +1 -0
  62. package/dist/default/theme.js +38 -0
  63. package/dist/default/theme.js.map +1 -0
  64. package/dist/default/types.d.ts +12 -0
  65. package/dist/default/types.d.ts.map +1 -0
  66. package/dist/default/types.js +2 -0
  67. package/dist/default/types.js.map +1 -0
  68. package/dist/flows/errors.d.ts +12 -0
  69. package/dist/flows/errors.d.ts.map +1 -0
  70. package/dist/flows/errors.js +13 -0
  71. package/dist/flows/errors.js.map +1 -0
  72. package/dist/flows/registry.d.ts +22 -0
  73. package/dist/flows/registry.d.ts.map +1 -0
  74. package/dist/flows/registry.js +20 -0
  75. package/dist/flows/registry.js.map +1 -0
  76. package/dist/headless/FormBuilder.d.ts +100 -0
  77. package/dist/headless/FormBuilder.d.ts.map +1 -0
  78. package/dist/headless/FormBuilder.js +278 -0
  79. package/dist/headless/FormBuilder.js.map +1 -0
  80. package/dist/headless/FormFill.d.ts +88 -0
  81. package/dist/headless/FormFill.d.ts.map +1 -0
  82. package/dist/headless/FormFill.js +279 -0
  83. package/dist/headless/FormFill.js.map +1 -0
  84. package/dist/headless/FormList.d.ts +34 -0
  85. package/dist/headless/FormList.d.ts.map +1 -0
  86. package/dist/headless/FormList.js +37 -0
  87. package/dist/headless/FormList.js.map +1 -0
  88. package/dist/headless/FormsProvider.d.ts +19 -0
  89. package/dist/headless/FormsProvider.d.ts.map +1 -0
  90. package/dist/headless/FormsProvider.js +15 -0
  91. package/dist/headless/FormsProvider.js.map +1 -0
  92. package/dist/headless/ResponsesTable.d.ts +82 -0
  93. package/dist/headless/ResponsesTable.d.ts.map +1 -0
  94. package/dist/headless/ResponsesTable.js +195 -0
  95. package/dist/headless/ResponsesTable.js.map +1 -0
  96. package/dist/i18n/errorsMap.d.ts +10 -0
  97. package/dist/i18n/errorsMap.d.ts.map +1 -0
  98. package/dist/i18n/errorsMap.js +20 -0
  99. package/dist/i18n/errorsMap.js.map +1 -0
  100. package/dist/i18n/es.d.ts +7 -0
  101. package/dist/i18n/es.d.ts.map +1 -0
  102. package/dist/i18n/es.js +118 -0
  103. package/dist/i18n/es.js.map +1 -0
  104. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  105. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  106. package/dist/i18n/generated/errors.es.gen.js +79 -0
  107. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  108. package/dist/i18n/generated/errors.gen.d.ts +485 -0
  109. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  110. package/dist/i18n/generated/errors.gen.js +246 -0
  111. package/dist/i18n/generated/errors.gen.js.map +1 -0
  112. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  113. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  114. package/dist/i18n/generated/errors.ru.gen.js +79 -0
  115. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  116. package/dist/i18n/keys.d.ts +141 -0
  117. package/dist/i18n/keys.d.ts.map +1 -0
  118. package/dist/i18n/keys.js +233 -0
  119. package/dist/i18n/keys.js.map +1 -0
  120. package/dist/i18n/ru.d.ts +7 -0
  121. package/dist/i18n/ru.d.ts.map +1 -0
  122. package/dist/i18n/ru.js +127 -0
  123. package/dist/i18n/ru.js.map +1 -0
  124. package/dist/index.d.ts +61 -0
  125. package/dist/index.d.ts.map +1 -0
  126. package/dist/index.js +55 -0
  127. package/dist/index.js.map +1 -0
  128. package/dist/model/context.d.ts +11 -0
  129. package/dist/model/context.d.ts.map +1 -0
  130. package/dist/model/context.js +16 -0
  131. package/dist/model/context.js.map +1 -0
  132. package/dist/model/mutations.d.ts +135 -0
  133. package/dist/model/mutations.d.ts.map +1 -0
  134. package/dist/model/mutations.js +223 -0
  135. package/dist/model/mutations.js.map +1 -0
  136. package/dist/model/queries.d.ts +59 -0
  137. package/dist/model/queries.d.ts.map +1 -0
  138. package/dist/model/queries.js +117 -0
  139. package/dist/model/queries.js.map +1 -0
  140. package/dist/model/queryKeys.d.ts +28 -0
  141. package/dist/model/queryKeys.d.ts.map +1 -0
  142. package/dist/model/queryKeys.js +23 -0
  143. package/dist/model/queryKeys.js.map +1 -0
  144. package/dist/model/runtime.d.ts +28 -0
  145. package/dist/model/runtime.d.ts.map +1 -0
  146. package/dist/model/runtime.js +14 -0
  147. package/dist/model/runtime.js.map +1 -0
  148. package/dist/widgets/configForms.d.ts +112 -0
  149. package/dist/widgets/configForms.d.ts.map +1 -0
  150. package/dist/widgets/configForms.js +211 -0
  151. package/dist/widgets/configForms.js.map +1 -0
  152. package/dist/widgets/registry.d.ts +83 -0
  153. package/dist/widgets/registry.d.ts.map +1 -0
  154. package/dist/widgets/registry.js +27 -0
  155. package/dist/widgets/registry.js.map +1 -0
  156. package/dist/widgets/validate.d.ts +57 -0
  157. package/dist/widgets/validate.d.ts.map +1 -0
  158. package/dist/widgets/validate.js +173 -0
  159. package/dist/widgets/validate.js.map +1 -0
  160. package/llms.txt +124 -0
  161. package/manifest.json +1038 -0
  162. package/package.json +108 -0
  163. package/src/analytics/generated/events.json +7 -0
  164. package/src/api/export.ts +195 -0
  165. package/src/api/extensions.ts +7 -0
  166. package/src/api/formsApi.ts +329 -0
  167. package/src/api/generated/schema.ts +989 -0
  168. package/src/api/types.ts +276 -0
  169. package/src/default/ConfigField.tsx +227 -0
  170. package/src/default/ErrorAlert.tsx +46 -0
  171. package/src/default/FormBuilderPane.tsx +414 -0
  172. package/src/default/FormsListPane.tsx +144 -0
  173. package/src/default/ResponsesPane.tsx +332 -0
  174. package/src/default/StapelForm.tsx +377 -0
  175. package/src/default/fields.tsx +454 -0
  176. package/src/default/index.ts +71 -0
  177. package/src/default/slots.ts +78 -0
  178. package/src/default/theme.tsx +65 -0
  179. package/src/default/types.ts +12 -0
  180. package/src/flows/errors.ts +16 -0
  181. package/src/flows/registry.ts +32 -0
  182. package/src/headless/FormBuilder.tsx +453 -0
  183. package/src/headless/FormFill.tsx +396 -0
  184. package/src/headless/FormList.tsx +74 -0
  185. package/src/headless/FormsProvider.tsx +20 -0
  186. package/src/headless/ResponsesTable.tsx +341 -0
  187. package/src/i18n/errorsMap.ts +31 -0
  188. package/src/i18n/es.ts +140 -0
  189. package/src/i18n/generated/errors.es.gen.ts +85 -0
  190. package/src/i18n/generated/errors.gen.ts +277 -0
  191. package/src/i18n/generated/errors.json +583 -0
  192. package/src/i18n/generated/errors.ru.gen.ts +85 -0
  193. package/src/i18n/keys.ts +264 -0
  194. package/src/i18n/ru.ts +150 -0
  195. package/src/index.ts +184 -0
  196. package/src/model/context.tsx +28 -0
  197. package/src/model/mutations.ts +387 -0
  198. package/src/model/queries.ts +154 -0
  199. package/src/model/queryKeys.ts +61 -0
  200. package/src/model/runtime.ts +48 -0
  201. package/src/widgets/registry.ts +101 -0
  202. package/src/widgets/validate.ts +224 -0
  203. package/tsconfig.json +26 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The field-widget registry — THE customer seam of this pair, and the direct
3
+ * descendant of docs-react's editor registry.
4
+ *
5
+ * A form schema's fields are `stapel_attributes` `FeatureDef`s, and that type
6
+ * vocabulary is an OPEN registry server-side (builtins →
7
+ * `STAPEL_ATTRIBUTES["EXTRA_TYPES"]` → runtime `register_feature_type`). A
8
+ * host that adds a type on the backend must be able to render it on the front
9
+ * without forking the skin:
10
+ *
11
+ * ```tsx
12
+ * registerFormFieldWidget("signature", SignaturePad); // at startup
13
+ * ```
14
+ *
15
+ * ── Resolution, and what happens when nothing matches ──────────────────────
16
+ *
17
+ * This module answers only the FIRST rung: an explicit registration, or
18
+ * `null`. The `/default` skin completes the ladder — explicit registration >
19
+ * skin builtin (antd) > **unsupported-field notice** — mirroring
20
+ * docs-react's `DocSurface`, where an explicit `registerDocEditor` outranks
21
+ * the skin's own styled default so a host's swap is never shadowed.
22
+ *
23
+ * The last rung is a notice, deliberately, and never a skipped field. A
24
+ * schema can legally contain a kind this build has no widget for; rendering
25
+ * nothing would silently drop a field that may be REQUIRED, and the person
26
+ * would submit a form they could not complete and be told, by the server,
27
+ * that a field they never saw is missing. `<FormFill>` therefore also blocks
28
+ * the submit with `forms.unsupported_kind` while such a field is present —
29
+ * degrade loudly (spec §7.2).
30
+ *
31
+ * The registry lives in the main entry, not in `/default`, so a host building
32
+ * its own renderer over `<FormFill>` uses the same seam as the skin does
33
+ * rather than a parallel one.
34
+ */
35
+ import type { ComponentType } from "react";
36
+ import type { FlowError } from "@stapel/core";
37
+ import type { FormFieldDef } from "../api/types.js";
38
+
39
+ /**
40
+ * What every field widget receives. One field, one value, one setter, one
41
+ * error — the widget never touches the API layer or the rest of the form.
42
+ */
43
+ export interface FormFieldWidgetProps {
44
+ /** The field's `FeatureDef`. `field.config` carries the kind's camelCase
45
+ * options (`maxLength`, `minSelected`, `precision`, `multiline`, …). */
46
+ readonly field: FormFieldDef;
47
+ /** The current answer, or `undefined` while unanswered. */
48
+ readonly value: unknown;
49
+ /** Report a new answer. Pass the bare scalar — `select` normalizes to a
50
+ * list server-side, so a single choice need not be wrapped. */
51
+ onChange(value: unknown): void;
52
+ /** The field's current refusal (client mirror or server verdict), or
53
+ * `undefined`. */
54
+ readonly error: FlowError | undefined;
55
+ /** True while a submit is in flight — widgets should go read-only. */
56
+ readonly disabled: boolean;
57
+ /**
58
+ * DOM id the widget MUST put on its primary control. The skin's field row
59
+ * points its `<label for>` at this, so the label actually names the input
60
+ * for a screen reader (and for a click). A widget that drops it renders an
61
+ * unlabelled control — which antd's `Form.Item` cannot detect and will
62
+ * happily draw a label beside.
63
+ */
64
+ readonly id: string;
65
+ }
66
+
67
+ export type FormFieldWidget = ComponentType<FormFieldWidgetProps>;
68
+
69
+ const registered = new Map<string, FormFieldWidget>();
70
+
71
+ /**
72
+ * Register (or override) the widget for a field kind. Call at startup, before
73
+ * the first render — the registry is module-global, like the i18n bundle
74
+ * registration and docs-react's editor registry.
75
+ */
76
+ export function registerFormFieldWidget(
77
+ kind: string,
78
+ widget: FormFieldWidget
79
+ ): void {
80
+ registered.set(kind, widget);
81
+ }
82
+
83
+ /** Remove an explicit registration (the skin's builtin, if any, resolves
84
+ * again). */
85
+ export function unregisterFormFieldWidget(kind: string): void {
86
+ registered.delete(kind);
87
+ }
88
+
89
+ /**
90
+ * The EXPLICIT registration for a kind, or `null`. Named for what it is: the
91
+ * skin calls this FIRST and only falls back to its own builtin when it
92
+ * returns `null`, so a host registration always wins.
93
+ */
94
+ export function resolveFormFieldWidget(kind: string): FormFieldWidget | null {
95
+ return registered.get(kind) ?? null;
96
+ }
97
+
98
+ /** Every kind with an explicit registration, sorted. */
99
+ export function registeredFormFieldKinds(): readonly string[] {
100
+ return [...registered.keys()].sort();
101
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * The client-side validation MIRROR — instant feedback, never a verdict.
3
+ *
4
+ * "Client-side validation mirrors, server decides" (spec §7.2). Every rule
5
+ * here is derived from the schema's own `config` so the person sees a problem
6
+ * as they type instead of after a round trip; none of it is trusted. The
7
+ * server re-runs `stapel_attributes.validate_dto_structured` on every submit
8
+ * and its answer is the one that counts.
9
+ *
10
+ * ── Why the mirror emits the SERVER's error keys ───────────────────────────
11
+ *
12
+ * Each rule below reports the exact `error.400.feature_*` key
13
+ * (`stapel_attributes.errors.ERROR_CODE_TO_KEY`) the backend would have
14
+ * returned for the same input, with the same `{feature}`/`{field}` params. So
15
+ * a "too long" caught locally and a "too long" caught by the server render
16
+ * the SAME sentence in the SAME language. The alternative — pair-invented
17
+ * copy for the local half — gives the person two different wordings for one
18
+ * problem and makes the local one a lie the moment the backend's rule moves.
19
+ *
20
+ * ── camelCase, and the cap that does not exist ─────────────────────────────
21
+ *
22
+ * Config keys are camelCase (`maxLength`, not `max_length`) — backend delta
23
+ * note 1. A type's dataclass parser drops keys it does not know, so a
24
+ * snake_case typo is a constraint that silently does not exist. `publish`
25
+ * refuses those server-side; this mirror simply reads the camelCase names and
26
+ * therefore agrees with the engine by construction.
27
+ */
28
+ import type { FlowError } from "@stapel/core";
29
+ import type { FormFieldDef } from "../api/types.js";
30
+
31
+ /** The `error.400.feature_*` keys this mirror can raise — a subset of
32
+ * `stapel_attributes.errors.ATTRIBUTES_ERRORS`, listed so the i18n bundle can
33
+ * be checked against it. */
34
+ export const FEATURE_ERROR_CODES: readonly string[] = [
35
+ "error.400.feature_below_minimum",
36
+ "error.400.feature_above_maximum",
37
+ "error.400.feature_not_in_options",
38
+ "error.400.feature_invalid_type",
39
+ "error.400.feature_invalid_format",
40
+ "error.400.feature_mandatory_missing",
41
+ ];
42
+
43
+ function featureError(
44
+ field: FormFieldDef,
45
+ code: string,
46
+ extra: Readonly<Record<string, unknown>> = {}
47
+ ): FlowError {
48
+ return {
49
+ code,
50
+ // `field` is what the fleet's `useFieldError` routes on; `slug` and
51
+ // `feature` are what the attributes catalogue's message templates
52
+ // interpolate. The adapter keeps all three server-side (delta note 1), so
53
+ // the mirror does too.
54
+ params: {
55
+ field: field.slug,
56
+ slug: field.slug,
57
+ feature: field.name ?? field.slug,
58
+ ...extra,
59
+ },
60
+ status: 400,
61
+ message: undefined,
62
+ language: undefined,
63
+ };
64
+ }
65
+
66
+ function num(config: Readonly<Record<string, unknown>>, key: string): number | undefined {
67
+ const raw = config[key];
68
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
69
+ }
70
+
71
+ /** The declared choice values of an options-bearing config, or `undefined`
72
+ * when the kind has none. Accepts both the bare-scalar and the
73
+ * `{value, label}` option shapes the attributes types allow. */
74
+ export function optionValues(
75
+ field: FormFieldDef
76
+ ): readonly unknown[] | undefined {
77
+ const raw = (field.config ?? {})["options"];
78
+ if (!Array.isArray(raw) || raw.length === 0) return undefined;
79
+ return raw.map((option) =>
80
+ option !== null && typeof option === "object" && "value" in option
81
+ ? (option as { value: unknown }).value
82
+ : option
83
+ );
84
+ }
85
+
86
+ /** True when the answer is "nothing was entered" — the one shape the
87
+ * mandatory rule fires on. An explicit `false` is an ANSWER for a `bool`, not
88
+ * an absence, which is why this is not a falsiness check. */
89
+ export function isBlank(value: unknown): boolean {
90
+ if (value === undefined || value === null) return true;
91
+ if (typeof value === "string") return value.trim().length === 0;
92
+ if (Array.isArray(value)) return value.length === 0;
93
+ return false;
94
+ }
95
+
96
+ /**
97
+ * Validate one answer against its field's config. Returns the mirrored
98
+ * refusal, or `undefined` when this side of the wire is satisfied.
99
+ *
100
+ * `header` fields are skipped outright: the engine regenerates a header's DAO
101
+ * from its config and REJECTS an answer to one (backend delta note 1), so a
102
+ * header has nothing to validate and must never be given a value.
103
+ */
104
+ export function validateFieldValue(
105
+ field: FormFieldDef,
106
+ value: unknown
107
+ ): FlowError | undefined {
108
+ if (field.kind === "header") return undefined;
109
+
110
+ const config = field.config ?? {};
111
+
112
+ if (isBlank(value)) {
113
+ return field.mandatory === true
114
+ ? featureError(field, "error.400.feature_mandatory_missing")
115
+ : // An optional field left blank is complete, and no later rule may
116
+ // fire on an absent value — a `minLength` must not turn "did not
117
+ // answer" into "answered too short".
118
+ undefined;
119
+ }
120
+
121
+ // ── choices ────────────────────────────────────────────────────────────
122
+ const options = optionValues(field);
123
+ if (options !== undefined && config["allowCustom"] !== true) {
124
+ const answers = Array.isArray(value) ? value : [value];
125
+ const stray = answers.find(
126
+ (answer) => !options.some((option) => option === answer)
127
+ );
128
+ if (stray !== undefined) {
129
+ return featureError(field, "error.400.feature_not_in_options", {
130
+ ref_value: stray,
131
+ });
132
+ }
133
+ }
134
+
135
+ // ── multi-select cardinality ───────────────────────────────────────────
136
+ if (field.kind === "select") {
137
+ const count = Array.isArray(value) ? value.length : 1;
138
+ const minSelected = num(config, "minSelected");
139
+ const maxSelected = num(config, "maxSelected");
140
+ if (minSelected !== undefined && count < minSelected) {
141
+ return featureError(field, "error.400.feature_below_minimum", {
142
+ min: minSelected,
143
+ });
144
+ }
145
+ if (maxSelected !== undefined && count > maxSelected) {
146
+ return featureError(field, "error.400.feature_above_maximum", {
147
+ max: maxSelected,
148
+ });
149
+ }
150
+ return undefined;
151
+ }
152
+
153
+ // ── numeric range ──────────────────────────────────────────────────────
154
+ if (field.kind === "int" || field.kind === "float") {
155
+ const asNumber = typeof value === "number" ? value : Number(value);
156
+ if (!Number.isFinite(asNumber)) {
157
+ return featureError(field, "error.400.feature_invalid_type");
158
+ }
159
+ if (field.kind === "int" && !Number.isInteger(asNumber)) {
160
+ return featureError(field, "error.400.feature_invalid_type");
161
+ }
162
+ const min = num(config, "min");
163
+ const max = num(config, "max");
164
+ if (min !== undefined && asNumber < min) {
165
+ return featureError(field, "error.400.feature_below_minimum", { min });
166
+ }
167
+ if (max !== undefined && asNumber > max) {
168
+ return featureError(field, "error.400.feature_above_maximum", { max });
169
+ }
170
+ return undefined;
171
+ }
172
+
173
+ // ── string length + pattern ────────────────────────────────────────────
174
+ if (field.kind === "string") {
175
+ const text = String(value);
176
+ const minLength = num(config, "minLength");
177
+ const maxLength = num(config, "maxLength");
178
+ if (minLength !== undefined && text.length < minLength) {
179
+ return featureError(field, "error.400.feature_below_minimum", {
180
+ min_length: minLength,
181
+ });
182
+ }
183
+ if (maxLength !== undefined && text.length > maxLength) {
184
+ return featureError(field, "error.400.feature_above_maximum", {
185
+ max_length: maxLength,
186
+ });
187
+ }
188
+ const pattern = config["pattern"];
189
+ if (typeof pattern === "string" && pattern.length > 0) {
190
+ let re: RegExp | undefined;
191
+ try {
192
+ re = new RegExp(pattern);
193
+ } catch {
194
+ // An admin-authored pattern that JS cannot compile (a Python-only
195
+ // construct) is NOT the respondent's problem: skip the mirror and
196
+ // let the server, which compiled it, be the one to refuse.
197
+ re = undefined;
198
+ }
199
+ if (re !== undefined && !re.test(text)) {
200
+ return featureError(field, "error.400.feature_invalid_format");
201
+ }
202
+ }
203
+ return undefined;
204
+ }
205
+
206
+ return undefined;
207
+ }
208
+
209
+ /**
210
+ * Validate a whole answer set. Returns `{slug: FlowError}` for the fields that
211
+ * failed — the exact shape `FormFillBag.fieldErrors` carries, so a server
212
+ * response can be merged into it without a translation step.
213
+ */
214
+ export function validateAnswers(
215
+ fields: readonly FormFieldDef[],
216
+ values: Readonly<Record<string, unknown>>
217
+ ): Readonly<Record<string, FlowError>> {
218
+ const errors: Record<string, FlowError> = {};
219
+ for (const field of fields) {
220
+ const error = validateFieldValue(field, values[field.slug]);
221
+ if (error !== undefined) errors[field.slug] = error;
222
+ }
223
+ return errors;
224
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "_comment": "Self-contained on purpose: standalone-buildable per frontend-standard §7. Mirrors the root tsconfig.base.json settings.",
4
+ "compilerOptions": {
5
+ "target": "ES2022",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "moduleResolution": "bundler",
9
+ "jsx": "react-jsx",
10
+ "strict": true,
11
+ "noUncheckedIndexedAccess": true,
12
+ "noImplicitOverride": true,
13
+ "exactOptionalPropertyTypes": true,
14
+ "isolatedModules": true,
15
+ "isolatedDeclarations": true,
16
+ "verbatimModuleSyntax": true,
17
+ "declaration": true,
18
+ "declarationMap": true,
19
+ "sourceMap": true,
20
+ "skipLibCheck": true,
21
+ "forceConsistentCasingInFileNames": true,
22
+ "outDir": "dist",
23
+ "rootDir": "src"
24
+ },
25
+ "include": ["src"]
26
+ }