@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,454 @@
1
+ /**
2
+ * The antd BUILTIN field widgets — one per attribute kind stapel-forms allows
3
+ * (`STAPEL_FORMS["FIELD_KINDS"]`, ten of them).
4
+ *
5
+ * ── Where these sit in the resolution ladder ───────────────────────────────
6
+ *
7
+ * explicit `registerFormFieldWidget(kind, …)` ← a host's, always wins
8
+ * → this table ← the skin's default
9
+ * → the unsupported-field notice ← loud, never silent
10
+ *
11
+ * The host's registration outranks the skin's builtin, mirroring docs-react's
12
+ * `DocSurface`, so "override without fork" is real and not a slogan: a host
13
+ * that dislikes this `select` replaces it in one line and keeps every other
14
+ * widget.
15
+ *
16
+ * ── Two deliberate departures from the spec's widget sketch ────────────────
17
+ *
18
+ * 1. **`date` uses a native `<input type=…>`, not antd's `DatePicker`.**
19
+ * `DatePicker` speaks Dayjs objects, which would make `dayjs` a runtime
20
+ * dependency of this pair for exactly one widget — and would put a
21
+ * format-guessing step between the person and the wire, where today the
22
+ * native control hands over the ISO string
23
+ * (`YYYY-MM-DD` / `YYYY-MM` / `YYYY-MM-DDTHH:mm`) that the attributes date
24
+ * type already parses. The `precision` config selects the input type, so
25
+ * "year only" is a number and "datetime" is a datetime-local. A host that
26
+ * wants the antd picker registers it — that is what the seam is for.
27
+ *
28
+ * 2. **`select` renders `Segmented` at ≤4 single-choice options, else
29
+ * `Select`** — the profiles-react `FieldRow` threshold, kept identical so
30
+ * the fleet's forms and its settings screens read the same way.
31
+ *
32
+ * Values are BARE SCALARS. The server normalizes a single `select` answer to
33
+ * a list, so no widget wraps its own value; the one exception is
34
+ * `convertible_unit`, whose wire DTO genuinely is an object
35
+ * (`{type, value, unit}` — `stapel_attributes.types.convertible_unit.dto`).
36
+ */
37
+ import { useMemo } from "react";
38
+ import type { ReactElement } from "react";
39
+ import {
40
+ Cascader,
41
+ ColorPicker,
42
+ Flex,
43
+ Input,
44
+ InputNumber,
45
+ Segmented,
46
+ Select,
47
+ Switch,
48
+ Typography,
49
+ } from "antd";
50
+ import { useT } from "@stapel/core";
51
+ import type { FormFieldDef } from "../api/types.js";
52
+ import type { FormFieldWidget, FormFieldWidgetProps } from "../widgets/registry.js";
53
+ import { optionValues } from "../widgets/validate.js";
54
+ import { FORMS_I18N_KEYS } from "../i18n/keys.js";
55
+
56
+ /** At or below this many choices a single-select renders as a `Segmented`
57
+ * (the profiles-react threshold — kept identical on purpose). */
58
+ const SEGMENTED_MAX_OPTIONS = 4;
59
+
60
+ /**
61
+ * antd's `status` prop under `exactOptionalPropertyTypes` does not accept
62
+ * `undefined` — it wants the key ABSENT. Spread this instead of passing
63
+ * `status={error ? "error" : undefined}`, which every control in this file
64
+ * would otherwise have to spell out.
65
+ */
66
+ function errorStatus(
67
+ error: unknown
68
+ ): { status: "error" } | Record<string, never> {
69
+ return error ? { status: "error" } : {};
70
+ }
71
+
72
+ function config(field: FormFieldDef): Readonly<Record<string, unknown>> {
73
+ return field.config ?? {};
74
+ }
75
+
76
+ function numberish(value: unknown): number | undefined {
77
+ if (typeof value === "number" && Number.isFinite(value)) return value;
78
+ if (typeof value === "string" && value.trim().length > 0) {
79
+ const parsed = Number(value);
80
+ if (Number.isFinite(parsed)) return parsed;
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ function str(value: unknown): string {
86
+ return typeof value === "string" ? value : value == null ? "" : String(value);
87
+ }
88
+
89
+ /** `{value, label}` choices for antd, from either option shape the attributes
90
+ * types allow (bare scalars, or `{value, label}` objects). */
91
+ function choiceList(
92
+ field: FormFieldDef
93
+ ): readonly { value: string; label: string }[] {
94
+ const raw = config(field)["options"];
95
+ if (!Array.isArray(raw)) return [];
96
+ return raw.map((option) => {
97
+ if (option !== null && typeof option === "object") {
98
+ const entry = option as { value?: unknown; label?: unknown };
99
+ const value = str(entry.value);
100
+ return { value, label: entry.label == null ? value : str(entry.label) };
101
+ }
102
+ return { value: str(option), label: str(option) };
103
+ });
104
+ }
105
+
106
+ // ── string ───────────────────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * `string` → `Input`, or `Input.TextArea` when `config.multiline` is set
110
+ * (stapel-attributes 0.4.6). Absent means single-line, which is what every
111
+ * schema published before that release carries — so nothing stored needs a
112
+ * migration and the widget needs no fallback heuristic.
113
+ */
114
+ const StringWidget: FormFieldWidget = (props: FormFieldWidgetProps) => {
115
+ const cfg = config(props.field);
116
+ const maxLength = numberish(cfg["maxLength"]);
117
+ const placeholder = str(cfg["placeholder"]);
118
+ const common = {
119
+ id: props.id,
120
+ value: str(props.value),
121
+ disabled: props.disabled,
122
+ ...errorStatus(props.error),
123
+ ...(maxLength !== undefined ? { maxLength } : {}),
124
+ ...(placeholder.length > 0 ? { placeholder } : {}),
125
+ };
126
+ return cfg["multiline"] === true ? (
127
+ <Input.TextArea
128
+ {...common}
129
+ autoSize={{ minRows: 3, maxRows: 8 }}
130
+ onChange={(event) => props.onChange(event.target.value)}
131
+ />
132
+ ) : (
133
+ <Input {...common} onChange={(event) => props.onChange(event.target.value)} />
134
+ );
135
+ };
136
+
137
+ // ── int / float ──────────────────────────────────────────────────────────────
138
+
139
+ function makeNumberWidget(isInt: boolean): FormFieldWidget {
140
+ const Widget = (props: FormFieldWidgetProps): ReactElement => {
141
+ const cfg = config(props.field);
142
+ const min = numberish(cfg["min"]);
143
+ const max = numberish(cfg["max"]);
144
+ const precision = isInt ? 0 : numberish(cfg["precision"]);
145
+ const placeholder = str(cfg["placeholder"]);
146
+ return (
147
+ <InputNumber
148
+ id={props.id}
149
+ style={{ width: "100%" }}
150
+ value={numberish(props.value) ?? null}
151
+ disabled={props.disabled}
152
+ {...errorStatus(props.error)}
153
+ {...(min !== undefined ? { min } : {})}
154
+ {...(max !== undefined ? { max } : {})}
155
+ {...(precision !== undefined ? { precision } : {})}
156
+ {...(isInt ? { step: 1 } : {})}
157
+ {...(placeholder.length > 0 ? { placeholder } : {})}
158
+ {...(str(cfg["prefix"]).length > 0 ? { prefix: str(cfg["prefix"]) } : {})}
159
+ {...(str(cfg["postfix"]).length > 0 ? { suffix: str(cfg["postfix"]) } : {})}
160
+ onChange={(next) => props.onChange(next ?? undefined)}
161
+ />
162
+ );
163
+ };
164
+ Widget.displayName = isInt ? "IntWidget" : "FloatWidget";
165
+ return Widget;
166
+ }
167
+
168
+ // ── bool ─────────────────────────────────────────────────────────────────────
169
+
170
+ /** `bool` → `Switch`, labelled with the config's own `trueLabel`/`falseLabel`
171
+ * when the admin supplied them. */
172
+ const BoolWidget: FormFieldWidget = (props: FormFieldWidgetProps) => {
173
+ const t = useT();
174
+ const cfg = config(props.field);
175
+ const on = props.value === true;
176
+ const trueLabel = str(cfg["trueLabel"]) || t(FORMS_I18N_KEYS.fillBoolYes);
177
+ const falseLabel = str(cfg["falseLabel"]) || t(FORMS_I18N_KEYS.fillBoolNo);
178
+ return (
179
+ <Flex align="center" gap={8}>
180
+ <Switch
181
+ id={props.id}
182
+ checked={on}
183
+ disabled={props.disabled}
184
+ onChange={(checked) => props.onChange(checked)}
185
+ />
186
+ <Typography.Text type="secondary">
187
+ {on ? trueLabel : falseLabel}
188
+ </Typography.Text>
189
+ </Flex>
190
+ );
191
+ };
192
+
193
+ // ── select ───────────────────────────────────────────────────────────────────
194
+
195
+ /**
196
+ * `select` → `Segmented` for a small single choice, `Select` otherwise
197
+ * (multiple when the config allows more than one).
198
+ *
199
+ * `maxSelected` absent means UNLIMITED — that is the engine's own
200
+ * `SelectConfig` default, and reading an absent key as 1 would silently turn
201
+ * every unconfigured select into a single-choice control.
202
+ */
203
+ const SelectWidget: FormFieldWidget = (props: FormFieldWidgetProps) => {
204
+ const t = useT();
205
+ const cfg = config(props.field);
206
+ const choices = choiceList(props.field);
207
+ const maxSelected = numberish(cfg["maxSelected"]);
208
+ const multiple = maxSelected === undefined || maxSelected > 1;
209
+
210
+ if (!multiple && choices.length > 0 && choices.length <= SEGMENTED_MAX_OPTIONS) {
211
+ const current = str(Array.isArray(props.value) ? props.value[0] : props.value);
212
+ return (
213
+ <Segmented<string>
214
+ id={props.id}
215
+ options={[...choices]}
216
+ value={current}
217
+ disabled={props.disabled}
218
+ {...errorStatus(props.error)}
219
+ onChange={(next) => props.onChange(next)}
220
+ />
221
+ );
222
+ }
223
+
224
+ const value = multiple
225
+ ? (Array.isArray(props.value) ? props.value : props.value == null ? [] : [props.value]).map(
226
+ str
227
+ )
228
+ : str(Array.isArray(props.value) ? props.value[0] : props.value) || undefined;
229
+
230
+ return (
231
+ <Select
232
+ id={props.id}
233
+ style={{ width: "100%" }}
234
+ options={[...choices]}
235
+ disabled={props.disabled}
236
+ {...errorStatus(props.error)}
237
+ placeholder={t(FORMS_I18N_KEYS.fillSelectPlaceholder)}
238
+ {...(multiple ? { mode: "multiple" as const } : {})}
239
+ {...(multiple && maxSelected !== undefined ? { maxCount: maxSelected } : {})}
240
+ // `allowCustom` is the engine's word for "a value outside options is
241
+ // acceptable"; antd's word for the same affordance is a tags mode.
242
+ {...(cfg["allowCustom"] === true && multiple
243
+ ? { mode: "tags" as const }
244
+ : {})}
245
+ value={value}
246
+ onChange={(next: unknown) => props.onChange(next)}
247
+ />
248
+ );
249
+ };
250
+
251
+ // ── date ─────────────────────────────────────────────────────────────────────
252
+
253
+ /** `precision` → the native input type that produces exactly the ISO string
254
+ * the attributes date type parses. */
255
+ const DATE_INPUT_TYPE: Readonly<Record<string, string>> = {
256
+ month: "month",
257
+ date: "date",
258
+ datetime: "datetime-local",
259
+ };
260
+
261
+ const DateWidget: FormFieldWidget = (props: FormFieldWidgetProps) => {
262
+ const cfg = config(props.field);
263
+ const precision = str(cfg["precision"]) || "date";
264
+
265
+ // "Year only" is a number, not a date: a date input would force a month and
266
+ // a day the admin explicitly said they do not want.
267
+ if (precision === "year") {
268
+ return (
269
+ <InputNumber
270
+ style={{ width: "100%" }}
271
+ value={numberish(props.value) ?? null}
272
+ disabled={props.disabled}
273
+ {...errorStatus(props.error)}
274
+ step={1}
275
+ precision={0}
276
+ onChange={(next) => props.onChange(next ?? undefined)}
277
+ />
278
+ );
279
+ }
280
+
281
+ return (
282
+ <Input
283
+ id={props.id}
284
+ type={DATE_INPUT_TYPE[precision] ?? "date"}
285
+ value={str(props.value)}
286
+ disabled={props.disabled}
287
+ {...errorStatus(props.error)}
288
+ {...(str(cfg["minDate"]).length > 0 ? { min: str(cfg["minDate"]) } : {})}
289
+ {...(str(cfg["maxDate"]).length > 0 ? { max: str(cfg["maxDate"]) } : {})}
290
+ onChange={(event) => props.onChange(event.target.value || undefined)}
291
+ />
292
+ );
293
+ };
294
+
295
+ // ── header ───────────────────────────────────────────────────────────────────
296
+
297
+ /**
298
+ * `header` → a caption, and NOT a control.
299
+ *
300
+ * It never calls `onChange`: the engine regenerates a header's DAO from its
301
+ * config and stapel-forms rejects an answer to one outright (backend delta
302
+ * note 1), so a header that could hold a value would only ever produce a
303
+ * refused submit. `config.style` is `l` (H1) or `m` (H2) — with the upstream
304
+ * default `"h2"` matching NEITHER (LN-B01, preserved), which is why anything
305
+ * that is not `l` reads as H2 rather than being looked up.
306
+ */
307
+ const HeaderWidget: FormFieldWidget = (props: FormFieldWidgetProps) => {
308
+ const level = str(config(props.field)["style"]) === "l" ? 3 : 4;
309
+ return (
310
+ <Typography.Title level={level} style={{ marginBottom: 0 }}>
311
+ {props.field.name ?? ""}
312
+ </Typography.Title>
313
+ );
314
+ };
315
+
316
+ // ── hex_color ────────────────────────────────────────────────────────────────
317
+
318
+ const HexColorWidget: FormFieldWidget = (props: FormFieldWidgetProps) => {
319
+ const presets = useMemo(() => {
320
+ const values = optionValues(props.field) ?? [];
321
+ return values.length > 0
322
+ ? [{ label: props.field.name ?? "", colors: values.map(str) }]
323
+ : undefined;
324
+ }, [props.field]);
325
+ const current = str(props.value);
326
+ return (
327
+ // antd's ColorPicker renders no labelable form control and accepts no
328
+ // `id`, so the field row's `<label for>` cannot reach it. The accessible
329
+ // name therefore comes from the trigger's own text (`showText`), and the
330
+ // wrapper carries the id so the label at least resolves to this region.
331
+ <span id={props.id}>
332
+ <ColorPicker
333
+ disabled={props.disabled}
334
+ format="hex"
335
+ {...(presets ? { presets } : {})}
336
+ {...(current.length > 0 ? { value: current } : {})}
337
+ onChange={(color) => props.onChange(color.toHexString())}
338
+ showText
339
+ />
340
+ </span>
341
+ );
342
+ };
343
+
344
+ // ── hierarchical_select ──────────────────────────────────────────────────────
345
+
346
+ /** `hierarchical_select` → `Cascader`. The config's nested
347
+ * `{value, label, children}` options are already the shape antd wants, and
348
+ * the answer is the path array the engine stores. */
349
+ const HierarchicalSelectWidget: FormFieldWidget = (props: FormFieldWidgetProps) => {
350
+ const t = useT();
351
+ const raw = config(props.field)["options"];
352
+ const options = Array.isArray(raw) ? raw : [];
353
+ const value = Array.isArray(props.value) ? props.value.map(str) : undefined;
354
+ return (
355
+ <Cascader
356
+ id={props.id}
357
+ style={{ width: "100%" }}
358
+ options={options as never}
359
+ disabled={props.disabled}
360
+ {...errorStatus(props.error)}
361
+ placeholder={t(FORMS_I18N_KEYS.fillSelectPlaceholder)}
362
+ {...(value ? { value } : {})}
363
+ onChange={(next: unknown) => props.onChange(next ?? undefined)}
364
+ />
365
+ );
366
+ };
367
+
368
+ // ── convertible_unit ─────────────────────────────────────────────────────────
369
+
370
+ /**
371
+ * `convertible_unit` → a number beside the unit it is expressed in.
372
+ *
373
+ * The one widget whose value is an OBJECT, because its wire DTO is:
374
+ * `{type: "convertible_unit", value, unit}` — the number as typed, tagged
375
+ * with which of the config's `unit_m` (metric) / `unit_i` (imperial) codes it
376
+ * is in. The server converts to the family's base unit before validating, so
377
+ * the widget must send the unit and must NOT convert anything itself.
378
+ *
379
+ * This kind has no upstream `config_form()` declaration, so it ships
380
+ * BUILDER-LESS (spec §12 risk 5) — it renders and submits correctly, it just
381
+ * cannot be configured in `<FormBuilderPane>`.
382
+ */
383
+ const ConvertibleUnitWidget: FormFieldWidget = (props: FormFieldWidgetProps) => {
384
+ const cfg = config(props.field);
385
+ const units = [str(cfg["unit_m"]), str(cfg["unit_i"])].filter(
386
+ (code) => code.length > 0
387
+ );
388
+ const current =
389
+ props.value !== null && typeof props.value === "object"
390
+ ? (props.value as { value?: unknown; unit?: unknown })
391
+ : {};
392
+ const unit = str(current.unit) || units[0] || "";
393
+ const amount = numberish(current.value);
394
+ const precision = numberish(cfg["precision"]);
395
+
396
+ const emit = (nextAmount: number | undefined, nextUnit: string): void => {
397
+ if (nextAmount === undefined) {
398
+ props.onChange(undefined);
399
+ return;
400
+ }
401
+ props.onChange({
402
+ type: "convertible_unit",
403
+ value: nextAmount,
404
+ ...(nextUnit.length > 0 ? { unit: nextUnit } : {}),
405
+ });
406
+ };
407
+
408
+ return (
409
+ <Flex gap={8}>
410
+ <InputNumber
411
+ id={props.id}
412
+ style={{ flex: 1 }}
413
+ value={amount ?? null}
414
+ disabled={props.disabled}
415
+ {...errorStatus(props.error)}
416
+ {...(precision !== undefined ? { precision } : {})}
417
+ onChange={(next) => emit(next ?? undefined, unit)}
418
+ />
419
+ {units.length > 0 && (
420
+ <Select
421
+ style={{ width: 96 }}
422
+ disabled={props.disabled}
423
+ {...errorStatus(props.error)}
424
+ value={unit}
425
+ options={units.map((code) => ({ value: code, label: code }))}
426
+ onChange={(next) => emit(amount, next)}
427
+ />
428
+ )}
429
+ </Flex>
430
+ );
431
+ };
432
+
433
+ /**
434
+ * The skin's builtin widget per kind — the second rung of the ladder. A kind
435
+ * absent from this table has no default drawing and reaches the
436
+ * unsupported-field notice.
437
+ */
438
+ export const BUILTIN_FIELD_WIDGETS: Readonly<Record<string, FormFieldWidget>> = {
439
+ string: StringWidget,
440
+ int: makeNumberWidget(true),
441
+ float: makeNumberWidget(false),
442
+ bool: BoolWidget,
443
+ select: SelectWidget,
444
+ date: DateWidget,
445
+ header: HeaderWidget,
446
+ hex_color: HexColorWidget,
447
+ hierarchical_select: HierarchicalSelectWidget,
448
+ convertible_unit: ConvertibleUnitWidget,
449
+ };
450
+
451
+ /** The kinds this skin can draw — handed to `<FormFill>` so the headless
452
+ * layer can judge `unsupportedKinds` without importing the skin. */
453
+ export const BUILTIN_FIELD_KINDS: readonly string[] =
454
+ Object.keys(BUILTIN_FIELD_WIDGETS).sort();
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `@stapel/forms-react/default` — the antd default skin.
3
+ *
4
+ * A SEPARATE entry point on purpose: the main entry is headless and carries
5
+ * no antd, so a host that renders its own visuals over `<FormFill>` never
6
+ * pays for this bundle (enforced by size-limit and the bundle-purity test).
7
+ *
8
+ * Three override levers, in increasing order of reach — none of which
9
+ * requires forking anything:
10
+ *
11
+ * 1. **props** (`mode`, `showTitle`, `submitLabel`);
12
+ * 2. **`registerFormFieldWidget(kind, …)`** — replace how one field kind
13
+ * draws; a host registration outranks every builtin here;
14
+ * 3. **`registerFormsSkinComponent(slot, …)`** — replace a piece of the
15
+ * skin itself (a field row, the submit bar, a response cell).
16
+ *
17
+ * And beneath all three: retheming through the §68 token JSON, which reaches
18
+ * this skin with zero code because every surface wraps itself in
19
+ * `<FormsSkinTheme>`.
20
+ */
21
+
22
+ // ── surfaces ────────────────────────────────────────────────────────────────
23
+ export { StapelForm } from "./StapelForm.js";
24
+ export type {
25
+ StapelFormProps,
26
+ FieldRowSlotProps,
27
+ SubmitBarSlotProps,
28
+ ConfirmationSlotProps,
29
+ UnsupportedFieldSlotProps,
30
+ } from "./StapelForm.js";
31
+
32
+ export { FormBuilderPane } from "./FormBuilderPane.js";
33
+ export type {
34
+ FormBuilderPaneProps,
35
+ BuilderToolbarSlotProps,
36
+ BuilderFieldRowSlotProps,
37
+ } from "./FormBuilderPane.js";
38
+
39
+ export { ResponsesPane } from "./ResponsesPane.js";
40
+ export type {
41
+ ResponsesPaneProps,
42
+ ResponseCellSlotProps,
43
+ ResponsesToolbarSlotProps,
44
+ } from "./ResponsesPane.js";
45
+
46
+ export { FormsListPane } from "./FormsListPane.js";
47
+ export type { FormsListPaneProps } from "./FormsListPane.js";
48
+
49
+ // ── theming ─────────────────────────────────────────────────────────────────
50
+ export { FormsSkinTheme } from "./theme.js";
51
+ export type { FormsSkinThemeProps } from "./theme.js";
52
+ export { ErrorAlert } from "./ErrorAlert.js";
53
+ export type { ThemeModeProp } from "./types.js";
54
+
55
+ // ── the two override registries ─────────────────────────────────────────────
56
+ export {
57
+ registerFormsSkinComponent,
58
+ unregisterFormsSkinComponent,
59
+ resolveFormsSkinComponent,
60
+ registeredFormsSkinSlots,
61
+ } from "./slots.js";
62
+ export type { FormsSkinSlot } from "./slots.js";
63
+
64
+ /** The skin's builtin widgets, exported so a host can WRAP one instead of
65
+ * replacing it outright (decorate the default rather than reimplement it). */
66
+ export { BUILTIN_FIELD_WIDGETS, BUILTIN_FIELD_KINDS } from "./fields.js";
67
+
68
+ /** The data-driven config-form row — exported for a host building its own
69
+ * builder over the same declarations. */
70
+ export { ConfigField } from "./ConfigField.js";
71
+ export type { ConfigFieldProps } from "./ConfigField.js";
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The skin-slot registry — the SECOND override lever of `/default`, beside
3
+ * the field-widget registry (`widgets/registry.ts`) and plain props.
4
+ *
5
+ * "A default the customer cannot swap is a decision, not a default"
6
+ * (spec §7.3). The widget registry lets a host replace how one FIELD KIND
7
+ * draws; this lets a host replace a piece of the SKIN — the row that wraps a
8
+ * field, the submit bar, the confirmation panel, a response cell — without
9
+ * forking the skin to get at it.
10
+ *
11
+ * ```tsx
12
+ * registerFormsSkinComponent("fill.submitBar", MySubmitBar);
13
+ * ```
14
+ *
15
+ * Slot names are a closed union on purpose: a typo in a string key would
16
+ * register a component that silently never renders, and "my override did
17
+ * nothing and nothing said so" is the exact failure this fleet keeps closing.
18
+ */
19
+ import type { ComponentType } from "react";
20
+
21
+ /**
22
+ * The slots `/default` renders through. Each name is `<surface>.<part>`.
23
+ *
24
+ * Adding a slot is additive; REMOVING one is a breaking change for any host
25
+ * that registered it, so a slot is only added where the default is genuinely
26
+ * a judgement call somebody might reasonably disagree with.
27
+ */
28
+ export type FormsSkinSlot =
29
+ /** Wraps one field: label, control, error, required marker. */
30
+ | "fill.fieldRow"
31
+ /** The submit button and everything beside it (captcha, blocked reason). */
32
+ | "fill.submitBar"
33
+ /** What replaces the form once the server accepts it. */
34
+ | "fill.confirmation"
35
+ /** The notice shown in place of a field whose kind nothing can draw. */
36
+ | "fill.unsupportedField"
37
+ /** One cell of the responses grid. */
38
+ | "responses.cell"
39
+ /** The responses toolbar: version filter, export, paging. */
40
+ | "responses.toolbar"
41
+ /** One row of the builder's field list. */
42
+ | "builder.fieldRow"
43
+ /** The builder's save/publish/state controls. */
44
+ | "builder.toolbar";
45
+
46
+ const slots = new Map<FormsSkinSlot, ComponentType<never>>();
47
+
48
+ /**
49
+ * Replace a slot's component. Call at startup, before the first render.
50
+ *
51
+ * The props a slot receives are the ones the skin passes it — each slot's
52
+ * prop type is exported beside the component that renders it, so an override
53
+ * is written against a real type rather than by reading the skin's source.
54
+ */
55
+ export function registerFormsSkinComponent<P>(
56
+ slot: FormsSkinSlot,
57
+ component: ComponentType<P>
58
+ ): void {
59
+ slots.set(slot, component as ComponentType<never>);
60
+ }
61
+
62
+ /** Remove an override, restoring the skin's own component. */
63
+ export function unregisterFormsSkinComponent(slot: FormsSkinSlot): void {
64
+ slots.delete(slot);
65
+ }
66
+
67
+ /** The host's override for a slot, or `null` — the skin falls back to its
68
+ * own component. */
69
+ export function resolveFormsSkinComponent<P>(
70
+ slot: FormsSkinSlot
71
+ ): ComponentType<P> | null {
72
+ return (slots.get(slot) as ComponentType<P> | undefined) ?? null;
73
+ }
74
+
75
+ /** Every slot with an override, sorted. */
76
+ export function registeredFormsSkinSlots(): readonly FormsSkinSlot[] {
77
+ return [...slots.keys()].sort();
78
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The default skin's INTERNAL theme provider — every `/default` surface wraps
3
+ * itself in this, so the skin is readable no matter what the host supplies
4
+ * (tracker #26: a skin set with no internal provider once inherited a theme
5
+ * bridge serving light-mode values inside a dark document — text on
6
+ * background at 1.00:1). The theme derives from `@stapel/tokens` via
7
+ * `@stapel/tokens-antd`'s `toAntdThemeConfig(mode)`; `mode` defaults to what
8
+ * the HOST's document declares (`resolveThemeMode()` — the `data-theme`
9
+ * attribute `tokens.css` keys its dark block on), never to a hardcoded side.
10
+ *
11
+ * This is also what "override styles" means for this pair (spec §7.3): a host
12
+ * retheming through the §68 token JSON regenerates its `--stapel-*` custom
13
+ * properties, and the form follows with zero code — which is a far better
14
+ * deal than a prop-per-colour API, and the reason the skin has none.
15
+ */
16
+ import { useMemo } from "react";
17
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
18
+ import { ConfigProvider } from "antd";
19
+ import {
20
+ resolveThemeMode,
21
+ toAntdTheme,
22
+ toAntdThemeConfig,
23
+ } from "@stapel/tokens-antd";
24
+ import type { ThemeMode } from "@stapel/tokens-antd";
25
+
26
+ export interface FormsSkinThemeProps {
27
+ /**
28
+ * Light or dark. Defaults to the mode the host's document declares
29
+ * (`resolveThemeMode()`). Pass explicitly to pin a side.
30
+ */
31
+ readonly mode?: ThemeMode;
32
+ /** Extra styles merged onto the themed root element (layout only — colors
33
+ * come from the mode). */
34
+ readonly style?: CSSProperties;
35
+ readonly children: ReactNode;
36
+ }
37
+
38
+ /**
39
+ * `<FormsSkinTheme/>` — the self-theming wrapper of
40
+ * `@stapel/forms-react/default`. Standalone use is supported (wrap any
41
+ * composition of the skin's parts once); the shipped surfaces
42
+ * (`StapelForm`, `FormBuilderPane`, `ResponsesPane`, `FormsListPane`) each
43
+ * already wrap themselves, and nested antd `ConfigProvider`s merge, so
44
+ * composing them under one `FormsSkinTheme` stays correct.
45
+ */
46
+ export function FormsSkinTheme(props: FormsSkinThemeProps): ReactElement {
47
+ const mode = props.mode ?? resolveThemeMode();
48
+ const theme = useMemo(() => toAntdThemeConfig(mode), [mode]);
49
+ const token = useMemo(() => toAntdTheme(mode), [mode]);
50
+ return (
51
+ <ConfigProvider theme={theme}>
52
+ <div
53
+ data-forms-skin-root
54
+ data-forms-skin-mode={mode}
55
+ style={{
56
+ color: token.colorText,
57
+ backgroundColor: token.colorBgContainer,
58
+ ...props.style,
59
+ }}
60
+ >
61
+ {props.children}
62
+ </div>
63
+ </ConfigProvider>
64
+ );
65
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Small shared types for the `/default` skin — kept in one place so every
3
+ * surface takes the same `mode` prop and re-exports the same error dialect.
4
+ */
5
+ export type { FlowError } from "@stapel/core";
6
+ import type { ThemeMode } from "@stapel/tokens-antd";
7
+
8
+ /** Every `/default` surface accepts a theme mode; absent means "whatever the
9
+ * host document declares" (`resolveThemeMode()`), never a hardcoded side. */
10
+ export interface ThemeModeProp {
11
+ readonly mode?: ThemeMode;
12
+ }