@businessdash/sdk 0.9.60

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 (268) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +899 -0
  3. package/dist/adapters-revalidate.d.ts +44 -0
  4. package/dist/adapters-revalidate.d.ts.map +1 -0
  5. package/dist/adapters-revalidate.js +68 -0
  6. package/dist/adapters-revalidate.js.map +1 -0
  7. package/dist/alpha.d.ts +21 -0
  8. package/dist/alpha.d.ts.map +1 -0
  9. package/dist/alpha.js +21 -0
  10. package/dist/alpha.js.map +1 -0
  11. package/dist/analytics-core.d.ts +34 -0
  12. package/dist/analytics-core.d.ts.map +1 -0
  13. package/dist/analytics-core.js +138 -0
  14. package/dist/analytics-core.js.map +1 -0
  15. package/dist/analytics-server.d.ts +54 -0
  16. package/dist/analytics-server.d.ts.map +1 -0
  17. package/dist/analytics-server.js +78 -0
  18. package/dist/analytics-server.js.map +1 -0
  19. package/dist/attribution-logo.d.ts +2 -0
  20. package/dist/attribution-logo.d.ts.map +1 -0
  21. package/dist/attribution-logo.js +5 -0
  22. package/dist/attribution-logo.js.map +1 -0
  23. package/dist/auth-handler.d.ts +92 -0
  24. package/dist/auth-handler.d.ts.map +1 -0
  25. package/dist/auth-handler.js +277 -0
  26. package/dist/auth-handler.js.map +1 -0
  27. package/dist/biab-forms.css +484 -0
  28. package/dist/cli.d.ts +46 -0
  29. package/dist/cli.d.ts.map +1 -0
  30. package/dist/cli.js +1824 -0
  31. package/dist/cli.js.map +1 -0
  32. package/dist/client.d.ts +1681 -0
  33. package/dist/client.d.ts.map +1 -0
  34. package/dist/client.js +2845 -0
  35. package/dist/client.js.map +1 -0
  36. package/dist/collections.d.ts +13 -0
  37. package/dist/collections.d.ts.map +1 -0
  38. package/dist/collections.js +13 -0
  39. package/dist/collections.js.map +1 -0
  40. package/dist/contracts.d.ts +14609 -0
  41. package/dist/contracts.d.ts.map +1 -0
  42. package/dist/contracts.js +2428 -0
  43. package/dist/contracts.js.map +1 -0
  44. package/dist/data-model-schema.d.ts +962 -0
  45. package/dist/data-model-schema.d.ts.map +1 -0
  46. package/dist/data-model-schema.js +2295 -0
  47. package/dist/data-model-schema.js.map +1 -0
  48. package/dist/distribution.d.ts +38 -0
  49. package/dist/distribution.d.ts.map +1 -0
  50. package/dist/distribution.js +61 -0
  51. package/dist/distribution.js.map +1 -0
  52. package/dist/element.d.ts +121 -0
  53. package/dist/element.d.ts.map +1 -0
  54. package/dist/element.js +276 -0
  55. package/dist/element.js.map +1 -0
  56. package/dist/enums.d.ts +25 -0
  57. package/dist/enums.d.ts.map +1 -0
  58. package/dist/enums.js +38 -0
  59. package/dist/enums.js.map +1 -0
  60. package/dist/errors.d.ts +195 -0
  61. package/dist/errors.d.ts.map +1 -0
  62. package/dist/errors.js +188 -0
  63. package/dist/errors.js.map +1 -0
  64. package/dist/forms-core/autocomplete.d.ts +37 -0
  65. package/dist/forms-core/autocomplete.d.ts.map +1 -0
  66. package/dist/forms-core/autocomplete.js +82 -0
  67. package/dist/forms-core/autocomplete.js.map +1 -0
  68. package/dist/forms-core/business-function.d.ts +61 -0
  69. package/dist/forms-core/business-function.d.ts.map +1 -0
  70. package/dist/forms-core/business-function.js +149 -0
  71. package/dist/forms-core/business-function.js.map +1 -0
  72. package/dist/forms-core/calculation.d.ts +72 -0
  73. package/dist/forms-core/calculation.d.ts.map +1 -0
  74. package/dist/forms-core/calculation.js +103 -0
  75. package/dist/forms-core/calculation.js.map +1 -0
  76. package/dist/forms-core/controller.d.ts +249 -0
  77. package/dist/forms-core/controller.d.ts.map +1 -0
  78. package/dist/forms-core/controller.js +590 -0
  79. package/dist/forms-core/controller.js.map +1 -0
  80. package/dist/forms-core/elements.d.ts +44 -0
  81. package/dist/forms-core/elements.d.ts.map +1 -0
  82. package/dist/forms-core/elements.js +68 -0
  83. package/dist/forms-core/elements.js.map +1 -0
  84. package/dist/forms-core/email-domains.d.ts +10 -0
  85. package/dist/forms-core/email-domains.d.ts.map +1 -0
  86. package/dist/forms-core/email-domains.js +69 -0
  87. package/dist/forms-core/email-domains.js.map +1 -0
  88. package/dist/forms-core/file-rules.d.ts +51 -0
  89. package/dist/forms-core/file-rules.d.ts.map +1 -0
  90. package/dist/forms-core/file-rules.js +105 -0
  91. package/dist/forms-core/file-rules.js.map +1 -0
  92. package/dist/forms-core/formula.d.ts +114 -0
  93. package/dist/forms-core/formula.d.ts.map +1 -0
  94. package/dist/forms-core/formula.js +1170 -0
  95. package/dist/forms-core/formula.js.map +1 -0
  96. package/dist/forms-core/index.d.ts +24 -0
  97. package/dist/forms-core/index.d.ts.map +1 -0
  98. package/dist/forms-core/index.js +28 -0
  99. package/dist/forms-core/index.js.map +1 -0
  100. package/dist/forms-core/units.d.ts +49 -0
  101. package/dist/forms-core/units.d.ts.map +1 -0
  102. package/dist/forms-core/units.js +255 -0
  103. package/dist/forms-core/units.js.map +1 -0
  104. package/dist/forms-core/us-states.d.ts +14 -0
  105. package/dist/forms-core/us-states.d.ts.map +1 -0
  106. package/dist/forms-core/us-states.js +66 -0
  107. package/dist/forms-core/us-states.js.map +1 -0
  108. package/dist/forms-core/validation.d.ts +266 -0
  109. package/dist/forms-core/validation.d.ts.map +1 -0
  110. package/dist/forms-core/validation.js +850 -0
  111. package/dist/forms-core/validation.js.map +1 -0
  112. package/dist/forms.d.ts +19 -0
  113. package/dist/forms.d.ts.map +1 -0
  114. package/dist/forms.js +29 -0
  115. package/dist/forms.js.map +1 -0
  116. package/dist/gallery-types.d.ts +32 -0
  117. package/dist/gallery-types.d.ts.map +1 -0
  118. package/dist/gallery-types.js +2 -0
  119. package/dist/gallery-types.js.map +1 -0
  120. package/dist/graphql.d.ts +97 -0
  121. package/dist/graphql.d.ts.map +1 -0
  122. package/dist/graphql.js +212 -0
  123. package/dist/graphql.js.map +1 -0
  124. package/dist/index.cjs +19448 -0
  125. package/dist/index.d.cts +19 -0
  126. package/dist/index.d.mts +19 -0
  127. package/dist/index.d.ts +19 -0
  128. package/dist/index.d.ts.map +1 -0
  129. package/dist/index.js +20 -0
  130. package/dist/index.js.map +1 -0
  131. package/dist/marketing-schema.d.ts +216 -0
  132. package/dist/marketing-schema.d.ts.map +1 -0
  133. package/dist/marketing-schema.js +200 -0
  134. package/dist/marketing-schema.js.map +1 -0
  135. package/dist/mcp.d.ts +42 -0
  136. package/dist/mcp.d.ts.map +1 -0
  137. package/dist/mcp.js +87 -0
  138. package/dist/mcp.js.map +1 -0
  139. package/dist/native.d.ts +45 -0
  140. package/dist/native.d.ts.map +1 -0
  141. package/dist/native.js +43 -0
  142. package/dist/native.js.map +1 -0
  143. package/dist/next-revalidate.d.ts +43 -0
  144. package/dist/next-revalidate.d.ts.map +1 -0
  145. package/dist/next-revalidate.js +84 -0
  146. package/dist/next-revalidate.js.map +1 -0
  147. package/dist/parallel-pages.d.ts +102 -0
  148. package/dist/parallel-pages.d.ts.map +1 -0
  149. package/dist/parallel-pages.js +108 -0
  150. package/dist/parallel-pages.js.map +1 -0
  151. package/dist/payment-icons.d.ts +66 -0
  152. package/dist/payment-icons.d.ts.map +1 -0
  153. package/dist/payment-icons.js +160 -0
  154. package/dist/payment-icons.js.map +1 -0
  155. package/dist/proxy.d.ts +67 -0
  156. package/dist/proxy.d.ts.map +1 -0
  157. package/dist/proxy.js +97 -0
  158. package/dist/proxy.js.map +1 -0
  159. package/dist/react-analytics.d.ts +39 -0
  160. package/dist/react-analytics.d.ts.map +1 -0
  161. package/dist/react-analytics.js +59 -0
  162. package/dist/react-analytics.js.map +1 -0
  163. package/dist/react-attribution.d.ts +99 -0
  164. package/dist/react-attribution.d.ts.map +1 -0
  165. package/dist/react-attribution.js +52 -0
  166. package/dist/react-attribution.js.map +1 -0
  167. package/dist/react-marketing.d.ts +181 -0
  168. package/dist/react-marketing.d.ts.map +1 -0
  169. package/dist/react-marketing.js +215 -0
  170. package/dist/react-marketing.js.map +1 -0
  171. package/dist/react.d.ts +727 -0
  172. package/dist/react.d.ts.map +1 -0
  173. package/dist/react.js +2418 -0
  174. package/dist/react.js.map +1 -0
  175. package/dist/reserved-paths.d.ts +24 -0
  176. package/dist/reserved-paths.d.ts.map +1 -0
  177. package/dist/reserved-paths.js +60 -0
  178. package/dist/reserved-paths.js.map +1 -0
  179. package/dist/revalidate-verify.d.ts +30 -0
  180. package/dist/revalidate-verify.d.ts.map +1 -0
  181. package/dist/revalidate-verify.js +74 -0
  182. package/dist/revalidate-verify.js.map +1 -0
  183. package/dist/sdk.d.ts +678 -0
  184. package/dist/sdk.d.ts.map +1 -0
  185. package/dist/sdk.js +801 -0
  186. package/dist/sdk.js.map +1 -0
  187. package/dist/seo.d.ts +218 -0
  188. package/dist/seo.d.ts.map +1 -0
  189. package/dist/seo.js +309 -0
  190. package/dist/seo.js.map +1 -0
  191. package/dist/socials.d.ts +47 -0
  192. package/dist/socials.d.ts.map +1 -0
  193. package/dist/socials.js +172 -0
  194. package/dist/socials.js.map +1 -0
  195. package/dist/static.d.ts +14 -0
  196. package/dist/static.d.ts.map +1 -0
  197. package/dist/static.js +14 -0
  198. package/dist/static.js.map +1 -0
  199. package/dist/url-pattern.d.ts +47 -0
  200. package/dist/url-pattern.d.ts.map +1 -0
  201. package/dist/url-pattern.js +124 -0
  202. package/dist/url-pattern.js.map +1 -0
  203. package/dist/vanilla.d.ts +77 -0
  204. package/dist/vanilla.d.ts.map +1 -0
  205. package/dist/vanilla.js +903 -0
  206. package/dist/vanilla.js.map +1 -0
  207. package/openapi.json +13779 -0
  208. package/package.json +204 -0
  209. package/src/adapters-revalidate.ts +88 -0
  210. package/src/alpha.ts +89 -0
  211. package/src/analytics-core.ts +175 -0
  212. package/src/analytics-server.ts +99 -0
  213. package/src/angular/biab-form.component.ts +1443 -0
  214. package/src/angular/index.ts +26 -0
  215. package/src/attribution-logo.ts +5 -0
  216. package/src/auth-handler.ts +352 -0
  217. package/src/cli.ts +2517 -0
  218. package/src/client.ts +3578 -0
  219. package/src/collections.ts +101 -0
  220. package/src/contracts.ts +3390 -0
  221. package/src/data-model-schema.ts +4042 -0
  222. package/src/distribution.ts +73 -0
  223. package/src/element.ts +321 -0
  224. package/src/enums.ts +65 -0
  225. package/src/errors.ts +338 -0
  226. package/src/forms-core/autocomplete.ts +97 -0
  227. package/src/forms-core/business-function.ts +209 -0
  228. package/src/forms-core/calculation.ts +167 -0
  229. package/src/forms-core/controller.ts +922 -0
  230. package/src/forms-core/elements.ts +86 -0
  231. package/src/forms-core/email-domains.ts +68 -0
  232. package/src/forms-core/file-rules.ts +123 -0
  233. package/src/forms-core/formula.ts +1339 -0
  234. package/src/forms-core/index.ts +122 -0
  235. package/src/forms-core/units.ts +308 -0
  236. package/src/forms-core/us-states.ts +68 -0
  237. package/src/forms-core/validation.ts +1037 -0
  238. package/src/forms.ts +63 -0
  239. package/src/gallery-types.ts +31 -0
  240. package/src/graphql.ts +295 -0
  241. package/src/index.ts +505 -0
  242. package/src/marketing-schema.ts +395 -0
  243. package/src/mcp.ts +108 -0
  244. package/src/native.ts +73 -0
  245. package/src/next-revalidate.ts +124 -0
  246. package/src/parallel-pages.ts +192 -0
  247. package/src/payment-icons.ts +205 -0
  248. package/src/proxy.ts +173 -0
  249. package/src/qwik.tsx +1234 -0
  250. package/src/react-analytics.tsx +76 -0
  251. package/src/react-attribution.tsx +192 -0
  252. package/src/react-marketing.tsx +338 -0
  253. package/src/react.tsx +5495 -0
  254. package/src/reserved-paths.ts +65 -0
  255. package/src/revalidate-verify.ts +98 -0
  256. package/src/sdk.ts +1671 -0
  257. package/src/seo.ts +488 -0
  258. package/src/socials.ts +230 -0
  259. package/src/solid.tsx +1466 -0
  260. package/src/static.ts +21 -0
  261. package/src/styles/biab-forms.css +484 -0
  262. package/src/svelte/BiabForm.svelte +1069 -0
  263. package/src/svelte/context.ts +57 -0
  264. package/src/svelte/index.ts +79 -0
  265. package/src/url-pattern.ts +153 -0
  266. package/src/vanilla.ts +1290 -0
  267. package/src/vue/BiabForm.vue +1538 -0
  268. package/src/vue/index.ts +119 -0
package/src/solid.tsx ADDED
@@ -0,0 +1,1466 @@
1
+ /**
2
+ * `@businessdash/sdk/solid` — the `<BiabForm>` drop-in for SolidJS.
3
+ *
4
+ * Ship mode: SOURCE (.tsx). This file is published as-is and compiled by the
5
+ * consumer's Solid/JSX toolchain (vite-plugin-solid / babel-preset-solid). It is
6
+ * NOT tsc-compiled inside biab-dev, so it has zero build artifact here.
7
+ *
8
+ * It is a thin, idiomatic Solid view over the framework-agnostic headless core
9
+ * (`createFormController`, exported from `@businessdash/sdk/forms`). All runtime
10
+ * behaviour — values, errors, condition-aware visibility, multi-step nav,
11
+ * concurrent-reveal gating, submit lifecycle, file uploads — lives in the core.
12
+ * The binding only:
13
+ *
14
+ * 1. Bridges the controller's subscribe/snapshot observable into Solid
15
+ * reactivity with `from()` (the canonical "external store → Signal" adapter).
16
+ * 2. Renders the snapshot's nested field tree by `type` using the `biab-*`
17
+ * light-DOM class convention (no shadow root) so the consumer's own CSS
18
+ * cascades onto every field.
19
+ * 3. Applies the published `schema.settings` (animation, multi-step + progress
20
+ * bar/steps, concurrent reveal) and the org icon, so the drop-in reproduces
21
+ * the dashboard's form preview 1:1.
22
+ *
23
+ * Data flows entirely through the SDK client (auth, caching, revalidation, HMAC,
24
+ * retries, CRM fan-out all stay invisible). SSR / RSC callers can hand a
25
+ * pre-fetched `schema` instead of a `client`.
26
+ *
27
+ * @module
28
+ */
29
+
30
+ import type { JSX } from "solid-js";
31
+ import {
32
+ type Accessor,
33
+ For,
34
+ Match,
35
+ Show,
36
+ Switch,
37
+ createContext,
38
+ createMemo,
39
+ createSignal,
40
+ createUniqueId,
41
+ from,
42
+ onCleanup,
43
+ onMount,
44
+ useContext,
45
+ } from "solid-js";
46
+
47
+ import type {
48
+ BiabClient,
49
+ FormFileValue,
50
+ FormSchema,
51
+ FormSubmitResult,
52
+ } from "./sdk.js";
53
+ import {
54
+ type CreateFormControllerOptions,
55
+ type FieldError,
56
+ type FormController,
57
+ type FormCoreField,
58
+ type FormSnapshot,
59
+ type FormSubmitOptions,
60
+ concurrentRevealCount,
61
+ createFormController,
62
+ evalCondition,
63
+ } from "./forms-core/index.js";
64
+ import {
65
+ type AutocompleteConfig,
66
+ resolveFieldAutocomplete,
67
+ } from "./forms-core/autocomplete.js";
68
+
69
+ // ─────────────────────────────────────────────────────────────────────────────
70
+ // Client context — optional Provider-based DI so a tree of `<BiabForm>`s can
71
+ // share one client instead of threading `client` through every prop.
72
+ // ─────────────────────────────────────────────────────────────────────────────
73
+
74
+ const BiabClientContext = createContext<Accessor<BiabClient | undefined>>();
75
+
76
+ export interface BiabProviderProps {
77
+ /** The SDK client shared with descendant `<BiabForm>`s. */
78
+ client: BiabClient;
79
+ children?: JSX.Element;
80
+ }
81
+
82
+ /**
83
+ * Provide a single `BiabClient` to a subtree. Any descendant `<BiabForm>` that
84
+ * doesn't get its own `client` prop falls back to this one. Optional — passing
85
+ * `client` directly to `<BiabForm>` works without a provider.
86
+ */
87
+ export function BiabProvider(props: BiabProviderProps): JSX.Element {
88
+ const value = createMemo(() => props.client);
89
+ return (
90
+ <BiabClientContext.Provider value={value}>
91
+ {props.children}
92
+ </BiabClientContext.Provider>
93
+ );
94
+ }
95
+
96
+ /** Read the client supplied by the nearest `<BiabProvider>` (or `undefined`). */
97
+ export function useBiabClient(): BiabClient | undefined {
98
+ const ctx = useContext(BiabClientContext);
99
+ return ctx?.();
100
+ }
101
+
102
+ // ─────────────────────────────────────────────────────────────────────────────
103
+ // createFormStore — bridge a FormController into a reactive Solid accessor.
104
+ //
105
+ // `from()` adopts the controller's subscribe/notify observable; we seed it with
106
+ // the current `snapshot()` so the accessor is valid synchronously (the core does
107
+ // NOT replay on subscribe). The returned accessor re-runs any tracking scope on
108
+ // every notify. Disposes the controller on cleanup.
109
+ // ─────────────────────────────────────────────────────────────────────────────
110
+
111
+ export interface BiabFormStore {
112
+ /** Reactive snapshot accessor — read it inside JSX / memos to track changes. */
113
+ snapshot: Accessor<FormSnapshot>;
114
+ /** The underlying controller (imperative escape hatch). */
115
+ controller: FormController;
116
+ }
117
+
118
+ /**
119
+ * Create a controller-backed reactive store. Kicks off `load()` on mount (so
120
+ * SSR-adopted schemas stay synchronous and client loads fire once), and tears
121
+ * the controller down on cleanup. Use this when you want the headless behaviour
122
+ * but your own markup; `<BiabForm>` uses it internally.
123
+ */
124
+ export function createFormStore(
125
+ options: CreateFormControllerOptions,
126
+ ): BiabFormStore {
127
+ const controller = createFormController(options);
128
+
129
+ // `from` wraps the subscribe contract: it calls subscribe(setter) and returns
130
+ // an accessor. We pre-seed with snapshot() because the core never replays on
131
+ // subscribe, so the very first read must not be undefined.
132
+ const tracked = from<FormSnapshot>((set) => {
133
+ set(controller.snapshot());
134
+ return controller.subscribe(set);
135
+ });
136
+ // `from` accessors are `T | undefined` until the first emit; the seed above
137
+ // makes that window empty, but narrow it for callers regardless.
138
+ const snapshot: Accessor<FormSnapshot> = () =>
139
+ tracked() ?? controller.snapshot();
140
+
141
+ onMount(() => {
142
+ void controller.load();
143
+ });
144
+ onCleanup(() => controller.destroy());
145
+
146
+ return { snapshot, controller };
147
+ }
148
+
149
+ // ─────────────────────────────────────────────────────────────────────────────
150
+ // CSS-hook helpers — mirror the dashboard renderer's `data-biab-*` attributes so
151
+ // org overrides written against the dashboard preview also target this drop-in.
152
+ // ─────────────────────────────────────────────────────────────────────────────
153
+
154
+ /** camelCase a label into a stable object-key slug (mirrors output-keys.ts). */
155
+ function slugifyLabel(raw: string): string {
156
+ const words = raw
157
+ .replace(/[^a-zA-Z0-9]+/g, " ")
158
+ .trim()
159
+ .split(/\s+/)
160
+ .filter(Boolean);
161
+ if (words.length === 0) return "";
162
+ const key = words
163
+ .map((w, i) =>
164
+ i === 0
165
+ ? w.toLowerCase()
166
+ : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(),
167
+ )
168
+ .join("");
169
+ return /^[0-9]/.test(key) ? `_${key}` : key;
170
+ }
171
+
172
+ /**
173
+ * The `data-biab-field` value: a published `key`, else an explicit `outputKey`,
174
+ * else a camelCase slug of the label, else the id. Lets orgs target one field.
175
+ */
176
+ function fieldCssKey(field: FormCoreField): string {
177
+ const published = typeof field.key === "string" ? field.key.trim() : "";
178
+ if (published) return published;
179
+ const explicit =
180
+ typeof field.outputKey === "string" ? field.outputKey.trim() : "";
181
+ if (explicit) return explicit;
182
+ const label = typeof field.label === "string" ? field.label.trim() : "";
183
+ return (label && slugifyLabel(label)) || field.id;
184
+ }
185
+
186
+ const STATIC_NO_LABEL = new Set(["title", "subtitle", "separator", "richtext"]);
187
+
188
+ // ─────────────────────────────────────────────────────────────────────────────
189
+ // FieldInput — render one value-bearing field by `type`. Solid-idiomatic: each
190
+ // `case` reads `props.value` reactively (the parent passes a live accessor) and
191
+ // writes back via `props.set`. Mirrors FormPreview's `FieldInput`.
192
+ // ─────────────────────────────────────────────────────────────────────────────
193
+
194
+ interface FieldInputProps {
195
+ field: FormCoreField;
196
+ value: Accessor<unknown>;
197
+ set: (next: unknown) => void;
198
+ uploadFile?: (file: File, filename?: string) => Promise<FormFileValue>;
199
+ uploading: Accessor<boolean>;
200
+ /** Resolve the HTML autofill token for this field (or `undefined` to omit). */
201
+ autocomplete?: (field: FormCoreField) => string | undefined;
202
+ }
203
+
204
+ function asString(v: unknown): string {
205
+ return typeof v === "string" ? v : "";
206
+ }
207
+
208
+ function FieldInput(props: FieldInputProps): JSX.Element {
209
+ const t = () => props.field.type;
210
+ const field = () => props.field;
211
+ // Resolved HTML autofill token for this field (or undefined to omit the
212
+ // attribute). Honors the global `<BiabForm autoComplete>` switch and per-field
213
+ // overrides; see forms-core/autocomplete.ts.
214
+ const ac = () => props.autocomplete?.(props.field);
215
+
216
+ return (
217
+ <Switch
218
+ fallback={
219
+ <input
220
+ class="biab-input"
221
+ type="text"
222
+ placeholder={field().placeholder ?? ""}
223
+ value={asString(props.value())}
224
+ onInput={(e) => props.set(e.currentTarget.value)}
225
+ autocomplete={ac()}
226
+ />
227
+ }
228
+ >
229
+ <Match when={t() === "title"}>
230
+ <h2 class="biab-field__title">{field().label || "Untitled"}</h2>
231
+ </Match>
232
+ <Match when={t() === "subtitle"}>
233
+ <h3 class="biab-field__subtitle">{field().label || ""}</h3>
234
+ </Match>
235
+ <Match when={t() === "separator"}>
236
+ <hr class="biab-field__separator" />
237
+ </Match>
238
+ <Match when={t() === "richtext"}>
239
+ {/* The published content is org-authored; render it as plain text to
240
+ keep the binding free of an HTML-sanitizer dependency. Consumers
241
+ needing rich HTML can override this field via the `field` slot. */}
242
+ <div class="biab-field__richtext">{field().content ?? ""}</div>
243
+ </Match>
244
+ <Match when={t() === "image"}>
245
+ <Show when={(field().imageConfig as { src?: string })?.src}>
246
+ {(src) => (
247
+ <img
248
+ class="biab-field__image"
249
+ src={src()}
250
+ alt={(field().imageConfig as { alt?: string })?.alt ?? ""}
251
+ />
252
+ )}
253
+ </Show>
254
+ </Match>
255
+
256
+ <Match when={t() === "textarea"}>
257
+ <textarea
258
+ class="biab-textarea"
259
+ placeholder={field().placeholder ?? ""}
260
+ rows={(field().textareaConfig as { rows?: number })?.rows ?? 4}
261
+ value={asString(props.value())}
262
+ onInput={(e) => props.set(e.currentTarget.value)}
263
+ autocomplete={ac()}
264
+ />
265
+ </Match>
266
+
267
+ <Match when={t() === "select"}>
268
+ <select
269
+ class="biab-input biab-select"
270
+ value={asString(props.value())}
271
+ onChange={(e) => props.set(e.currentTarget.value)}
272
+ >
273
+ <option value="" disabled>
274
+ {field().placeholder || "Select..."}
275
+ </option>
276
+ <For each={field().options ?? []}>
277
+ {(opt) => <option value={opt}>{opt}</option>}
278
+ </For>
279
+ </select>
280
+ </Match>
281
+
282
+ <Match when={t() === "radio"}>
283
+ <div class="biab-radio-group">
284
+ <For each={field().options ?? []}>
285
+ {(opt) => {
286
+ const id = `${field().id}-${opt}`;
287
+ return (
288
+ <label class="biab-radio" for={id}>
289
+ <input
290
+ type="radio"
291
+ id={id}
292
+ name={field().id}
293
+ value={opt}
294
+ checked={asString(props.value()) === opt}
295
+ onChange={() => props.set(opt)}
296
+ />
297
+ <span class="biab-radio__label">{opt}</span>
298
+ </label>
299
+ );
300
+ }}
301
+ </For>
302
+ </div>
303
+ </Match>
304
+
305
+ <Match when={t() === "checkbox"}>
306
+ <div class="biab-checkbox-group">
307
+ <For each={field().options ?? []}>
308
+ {(opt) => {
309
+ const arr = () =>
310
+ Array.isArray(props.value())
311
+ ? (props.value() as string[])
312
+ : [];
313
+ return (
314
+ <label class="biab-checkbox">
315
+ <input
316
+ type="checkbox"
317
+ value={opt}
318
+ checked={arr().includes(opt)}
319
+ onChange={(e) => {
320
+ const cur = arr();
321
+ props.set(
322
+ e.currentTarget.checked
323
+ ? [...cur, opt]
324
+ : cur.filter((x) => x !== opt),
325
+ );
326
+ }}
327
+ />
328
+ <span class="biab-checkbox__label">{opt}</span>
329
+ </label>
330
+ );
331
+ }}
332
+ </For>
333
+ </div>
334
+ </Match>
335
+
336
+ <Match when={t() === "boolean"}>
337
+ <label class="biab-switch">
338
+ <input
339
+ type="checkbox"
340
+ checked={props.value() === true}
341
+ onChange={(e) => props.set(e.currentTarget.checked)}
342
+ />
343
+ <span class="biab-switch__label">
344
+ {field().placeholder || "Yes / No"}
345
+ </span>
346
+ </label>
347
+ </Match>
348
+
349
+ <Match when={t() === "date"}>
350
+ <input
351
+ class="biab-input"
352
+ type="date"
353
+ value={asString(props.value())}
354
+ onInput={(e) => props.set(e.currentTarget.value)}
355
+ />
356
+ </Match>
357
+
358
+ <Match when={t() === "email"}>
359
+ <input
360
+ class="biab-input"
361
+ type="email"
362
+ placeholder={field().placeholder || "you@example.com"}
363
+ value={asString(props.value())}
364
+ onInput={(e) => props.set(e.currentTarget.value)}
365
+ autocomplete={ac()}
366
+ />
367
+ </Match>
368
+
369
+ <Match when={t() === "phone"}>
370
+ <div class="biab-phone">
371
+ <Show when={(field().phoneConfig as { showFlags?: boolean })?.showFlags}>
372
+ <span class="biab-phone__flag" aria-hidden="true">
373
+ 🇺🇸
374
+ </span>
375
+ </Show>
376
+ <input
377
+ class="biab-input"
378
+ type="tel"
379
+ placeholder={
380
+ field().placeholder ||
381
+ ((field().phoneConfig as { international?: boolean })?.international
382
+ ? "+1 (555) 000-0000"
383
+ : "(555) 000-0000")
384
+ }
385
+ value={asString(props.value())}
386
+ onInput={(e) => props.set(e.currentTarget.value)}
387
+ autocomplete={ac()}
388
+ />
389
+ </div>
390
+ </Match>
391
+
392
+ <Match when={t() === "number"}>
393
+ <input
394
+ class="biab-input"
395
+ type="number"
396
+ placeholder={field().placeholder ?? ""}
397
+ value={
398
+ typeof props.value() === "number"
399
+ ? (props.value() as number)
400
+ : asString(props.value())
401
+ }
402
+ onInput={(e) =>
403
+ props.set(
404
+ e.currentTarget.value === ""
405
+ ? ""
406
+ : Number(e.currentTarget.value),
407
+ )
408
+ }
409
+ />
410
+ </Match>
411
+
412
+ <Match when={t() === "color_picker"}>
413
+ <div class="biab-color-group">
414
+ <For each={field().options ?? []}>
415
+ {(color) => (
416
+ <button
417
+ type="button"
418
+ class="biab-color-swatch"
419
+ classList={{
420
+ "biab-color-swatch--selected":
421
+ asString(props.value()) === color,
422
+ }}
423
+ style={{ "background-color": color }}
424
+ title={color}
425
+ onClick={() => props.set(color)}
426
+ />
427
+ )}
428
+ </For>
429
+ </div>
430
+ </Match>
431
+
432
+ <Match when={t() === "slider"}>
433
+ {(() => {
434
+ const cfg = () =>
435
+ (field().sliderConfig ?? {}) as {
436
+ min?: number;
437
+ max?: number;
438
+ step?: number;
439
+ unit?: string;
440
+ };
441
+ const min = () => cfg().min ?? 0;
442
+ const max = () => cfg().max ?? 100;
443
+ const cur = () =>
444
+ typeof props.value() === "number"
445
+ ? (props.value() as number)
446
+ : min();
447
+ const fmt = (n: number) =>
448
+ cfg().unit === "currency"
449
+ ? `$${n}`
450
+ : cfg().unit === "percent"
451
+ ? `${n}%`
452
+ : `${n}`;
453
+ return (
454
+ <div class="biab-slider">
455
+ <div class="biab-slider__scale">
456
+ <span>{fmt(min())}</span>
457
+ <span class="biab-slider__value">{fmt(cur())}</span>
458
+ <span>{fmt(max())}</span>
459
+ </div>
460
+ <input
461
+ type="range"
462
+ min={min()}
463
+ max={max()}
464
+ step={cfg().step ?? 1}
465
+ value={cur()}
466
+ onInput={(e) => props.set(Number(e.currentTarget.value))}
467
+ />
468
+ </div>
469
+ );
470
+ })()}
471
+ </Match>
472
+
473
+ <Match when={t() === "stepper"}>
474
+ {(() => {
475
+ const cur = () =>
476
+ typeof props.value() === "number" ? (props.value() as number) : 0;
477
+ return (
478
+ <div class="biab-stepper">
479
+ <button
480
+ type="button"
481
+ class="biab-stepper__btn"
482
+ onClick={() => props.set(cur() - 1)}
483
+ >
484
+
485
+ </button>
486
+ <span class="biab-stepper__value">{cur()}</span>
487
+ <button
488
+ type="button"
489
+ class="biab-stepper__btn"
490
+ onClick={() => props.set(cur() + 1)}
491
+ >
492
+ +
493
+ </button>
494
+ </div>
495
+ );
496
+ })()}
497
+ </Match>
498
+
499
+ <Match when={t() === "star_rating"}>
500
+ {(() => {
501
+ const maxStars = () =>
502
+ (field().starRatingConfig as { maxStars?: number })?.maxStars ?? 5;
503
+ const cur = () =>
504
+ typeof props.value() === "number" ? (props.value() as number) : 0;
505
+ return (
506
+ <div class="biab-stars">
507
+ <For each={Array.from({ length: maxStars() })}>
508
+ {(_, i) => (
509
+ <button
510
+ type="button"
511
+ class="biab-stars__star"
512
+ classList={{ "biab-stars__star--on": i() < cur() }}
513
+ aria-label={`${i() + 1} star`}
514
+ onClick={() => props.set(i() + 1)}
515
+ >
516
+
517
+ </button>
518
+ )}
519
+ </For>
520
+ </div>
521
+ );
522
+ })()}
523
+ </Match>
524
+
525
+ <Match when={t() === "happiness_rating"}>
526
+ {(() => {
527
+ const cur = () =>
528
+ typeof props.value() === "number" ? (props.value() as number) : -1;
529
+ const emojis = ["😞", "😕", "😐", "🙂", "😄"];
530
+ return (
531
+ <div class="biab-happiness">
532
+ <For each={emojis}>
533
+ {(emoji, i) => (
534
+ <button
535
+ type="button"
536
+ class="biab-happiness__face"
537
+ classList={{ "biab-happiness__face--on": i() === cur() }}
538
+ onClick={() => props.set(i())}
539
+ >
540
+ {emoji}
541
+ </button>
542
+ )}
543
+ </For>
544
+ </div>
545
+ );
546
+ })()}
547
+ </Match>
548
+
549
+ <Match when={t() === "time_picker"}>
550
+ {(() => {
551
+ const cfg = () =>
552
+ (field().timeConfig ?? {}) as {
553
+ mode?: "simple" | "time_block" | "appointment";
554
+ format?: "12h" | "24h";
555
+ stepMinutes?: number;
556
+ };
557
+ const mode = () => cfg().mode ?? "simple";
558
+ return (
559
+ <Switch>
560
+ <Match when={mode() === "simple"}>
561
+ {(() => {
562
+ const step = () => Math.max(cfg().stepMinutes ?? 30, 1);
563
+ const opts = createMemo(() => {
564
+ const out: string[] = [];
565
+ for (let h = 0; h < 24; h++) {
566
+ for (let m = 0; m < 60; m += step()) {
567
+ out.push(
568
+ `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`,
569
+ );
570
+ }
571
+ }
572
+ return out;
573
+ });
574
+ const label = (tt: string) => {
575
+ if (cfg().format === "24h") return tt;
576
+ const [hStr, mStr = "00"] = tt.split(":");
577
+ const h = Number(hStr);
578
+ const ampm = h < 12 ? "AM" : "PM";
579
+ const h12 = h % 12 === 0 ? 12 : h % 12;
580
+ return `${h12}:${mStr} ${ampm}`;
581
+ };
582
+ return (
583
+ <select
584
+ class="biab-input biab-select"
585
+ value={asString(props.value())}
586
+ onChange={(e) => props.set(e.currentTarget.value)}
587
+ >
588
+ <option value="" disabled>
589
+ {field().placeholder || "Select time"}
590
+ </option>
591
+ <For each={opts()}>
592
+ {(o) => <option value={o}>{label(o)}</option>}
593
+ </For>
594
+ </select>
595
+ );
596
+ })()}
597
+ </Match>
598
+ <Match when={mode() === "time_block"}>
599
+ {(() => {
600
+ const v = () =>
601
+ (props.value() &&
602
+ typeof props.value() === "object"
603
+ ? (props.value() as { start?: string; end?: string })
604
+ : { start: "", end: "" });
605
+ return (
606
+ <div class="biab-time-block">
607
+ <label class="biab-subfield">
608
+ <span class="biab-subfield__label">Start</span>
609
+ <input
610
+ class="biab-input"
611
+ type="time"
612
+ value={v().start ?? ""}
613
+ onInput={(e) =>
614
+ props.set({ ...v(), start: e.currentTarget.value })
615
+ }
616
+ />
617
+ </label>
618
+ <label class="biab-subfield">
619
+ <span class="biab-subfield__label">End</span>
620
+ <input
621
+ class="biab-input"
622
+ type="time"
623
+ value={v().end ?? ""}
624
+ onInput={(e) =>
625
+ props.set({ ...v(), end: e.currentTarget.value })
626
+ }
627
+ />
628
+ </label>
629
+ </div>
630
+ );
631
+ })()}
632
+ </Match>
633
+ <Match when={mode() === "appointment"}>
634
+ {(() => {
635
+ const v = () =>
636
+ (props.value() &&
637
+ typeof props.value() === "object"
638
+ ? (props.value() as { date?: string; time?: string })
639
+ : { date: "", time: "" });
640
+ return (
641
+ <div class="biab-appointment">
642
+ <label class="biab-subfield">
643
+ <span class="biab-subfield__label">Date</span>
644
+ <input
645
+ class="biab-input"
646
+ type="date"
647
+ value={v().date ?? ""}
648
+ onInput={(e) =>
649
+ props.set({ ...v(), date: e.currentTarget.value })
650
+ }
651
+ />
652
+ </label>
653
+ <label class="biab-subfield">
654
+ <span class="biab-subfield__label">Time</span>
655
+ <input
656
+ class="biab-input"
657
+ type="time"
658
+ value={v().time ?? ""}
659
+ onInput={(e) =>
660
+ props.set({ ...v(), time: e.currentTarget.value })
661
+ }
662
+ />
663
+ </label>
664
+ </div>
665
+ );
666
+ })()}
667
+ </Match>
668
+ </Switch>
669
+ );
670
+ })()}
671
+ </Match>
672
+
673
+ <Match when={t() === "date_range"}>
674
+ {(() => {
675
+ const v = () =>
676
+ (props.value() && typeof props.value() === "object"
677
+ ? (props.value() as { start?: string; end?: string })
678
+ : { start: "", end: "" });
679
+ return (
680
+ <div class="biab-date-range">
681
+ <label class="biab-subfield">
682
+ <span class="biab-subfield__label">Start</span>
683
+ <input
684
+ class="biab-input"
685
+ type="date"
686
+ value={v().start ?? ""}
687
+ onInput={(e) =>
688
+ props.set({ ...v(), start: e.currentTarget.value })
689
+ }
690
+ />
691
+ </label>
692
+ <label class="biab-subfield">
693
+ <span class="biab-subfield__label">End</span>
694
+ <input
695
+ class="biab-input"
696
+ type="date"
697
+ value={v().end ?? ""}
698
+ onInput={(e) =>
699
+ props.set({ ...v(), end: e.currentTarget.value })
700
+ }
701
+ />
702
+ </label>
703
+ </div>
704
+ );
705
+ })()}
706
+ </Match>
707
+
708
+ <Match when={t() === "address"}>
709
+ {(() => {
710
+ const v = () =>
711
+ (props.value() && typeof props.value() === "object"
712
+ ? (props.value() as Record<string, string>)
713
+ : {}) as Record<string, string>;
714
+ // Fixed per-subfield autofill tokens, suppressed to "off" when the
715
+ // field's resolved token is "off" (global switch or per-field override).
716
+ const acOff = () => ac() === "off";
717
+ const subAc = (token: string) => (acOff() ? "off" : token);
718
+ const part = (k: string, label: string, token: string) => (
719
+ <label class="biab-subfield">
720
+ <span class="biab-subfield__label">{label}</span>
721
+ <input
722
+ class="biab-input"
723
+ value={v()[k] ?? ""}
724
+ onInput={(e) =>
725
+ props.set({ ...v(), [k]: e.currentTarget.value })
726
+ }
727
+ autocomplete={subAc(token)}
728
+ />
729
+ </label>
730
+ );
731
+ return (
732
+ <div class="biab-address">
733
+ {part("line1", "Address Line 1", "street-address")}
734
+ {part("line2", "Address Line 2", "address-line2")}
735
+ {part("city", "City", "address-level2")}
736
+ {part("state", "State", "address-level1")}
737
+ {part("zip", "Zip", "postal-code")}
738
+ </div>
739
+ );
740
+ })()}
741
+ </Match>
742
+
743
+ <Match when={t() === "file_upload"}>
744
+ {(() => {
745
+ const files = () =>
746
+ Array.isArray(props.value())
747
+ ? (props.value() as FormFileValue[])
748
+ : [];
749
+ const cfg = () =>
750
+ (field().fileConfig ?? {}) as {
751
+ allowedTypes?: string[];
752
+ maxFiles?: number;
753
+ maxSize?: number;
754
+ };
755
+ const maxFiles = () => cfg().maxFiles ?? 1;
756
+ const remaining = () => Math.max(maxFiles() - files().length, 0);
757
+ const accept = () =>
758
+ cfg().allowedTypes && cfg().allowedTypes.length > 0
759
+ ? cfg().allowedTypes.join(",")
760
+ : undefined;
761
+ let inputRef: HTMLInputElement | undefined;
762
+ const [localError, setLocalError] = createSignal<string | null>(null);
763
+
764
+ const pick = async (list: FileList | null) => {
765
+ if (!list || list.length === 0) return;
766
+ if (!props.uploadFile) return;
767
+ setLocalError(null);
768
+ const allowed = cfg().allowedTypes;
769
+ const maxSizeMb = cfg().maxSize;
770
+ const chosen = Array.from(list).slice(0, remaining());
771
+ if (allowed && allowed.length > 0) {
772
+ const bad = chosen.find((f) => !allowed.includes(f.type));
773
+ if (bad) {
774
+ setLocalError(`File type not allowed: ${bad.type || bad.name}`);
775
+ return;
776
+ }
777
+ }
778
+ if (typeof maxSizeMb === "number" && maxSizeMb > 0) {
779
+ const tooBig = chosen.find(
780
+ (f) => f.size > maxSizeMb * 1024 * 1024,
781
+ );
782
+ if (tooBig) {
783
+ setLocalError(
784
+ `"${tooBig.name}" exceeds ${maxSizeMb} MB limit.`,
785
+ );
786
+ return;
787
+ }
788
+ }
789
+ try {
790
+ for (const f of chosen) {
791
+ // The controller appends each uploaded record to the field
792
+ // value, so we don't merge here — just trigger the upload.
793
+ await props.uploadFile(f, f.name);
794
+ }
795
+ } catch (err) {
796
+ setLocalError(
797
+ err instanceof Error ? err.message : "Upload failed.",
798
+ );
799
+ } finally {
800
+ if (inputRef) inputRef.value = "";
801
+ }
802
+ };
803
+
804
+ return (
805
+ <div class="biab-file">
806
+ <input
807
+ ref={inputRef}
808
+ type="file"
809
+ class="biab-file__input"
810
+ multiple={maxFiles() > 1}
811
+ accept={accept()}
812
+ style={{ display: "none" }}
813
+ onChange={(e) => void pick(e.currentTarget.files)}
814
+ />
815
+ <button
816
+ type="button"
817
+ class="biab-file__trigger"
818
+ disabled={props.uploading() || remaining() === 0 || !props.uploadFile}
819
+ onClick={() => inputRef?.click()}
820
+ >
821
+ <Show
822
+ when={!props.uploading()}
823
+ fallback={<span>Uploading…</span>}
824
+ >
825
+ <Show
826
+ when={remaining() > 0}
827
+ fallback={
828
+ <span>
829
+ Maximum {maxFiles()} file{maxFiles() === 1 ? "" : "s"}{" "}
830
+ reached
831
+ </span>
832
+ }
833
+ >
834
+ <span>Click to upload</span>
835
+ </Show>
836
+ </Show>
837
+ </button>
838
+ <Show when={localError()}>
839
+ <p class="biab-file__error">{localError()}</p>
840
+ </Show>
841
+ <Show when={files().length > 0}>
842
+ <ul class="biab-file__list">
843
+ <For each={files()}>
844
+ {(f, i) => (
845
+ <li class="biab-file__item">
846
+ <span class="biab-file__name">{f.name}</span>
847
+ <span class="biab-file__size">
848
+ {Math.round(f.size / 1024)} KB
849
+ </span>
850
+ <button
851
+ type="button"
852
+ class="biab-file__remove"
853
+ aria-label="Remove file"
854
+ onClick={() =>
855
+ props.set(
856
+ files().filter((_, idx) => idx !== i()),
857
+ )
858
+ }
859
+ >
860
+ ×
861
+ </button>
862
+ </li>
863
+ )}
864
+ </For>
865
+ </ul>
866
+ </Show>
867
+ </div>
868
+ );
869
+ })()}
870
+ </Match>
871
+ </Switch>
872
+ );
873
+ }
874
+
875
+ // ─────────────────────────────────────────────────────────────────────────────
876
+ // FieldRow — a value-bearing field plus its label / required marker / error /
877
+ // help text. Mirrors FormPreview's `FieldRow` + its `data-biab-*` hooks.
878
+ // ─────────────────────────────────────────────────────────────────────────────
879
+
880
+ interface RenderCtx {
881
+ snapshot: Accessor<FormSnapshot>;
882
+ setValue: (id: string, value: unknown) => void;
883
+ upload: (fieldId: string, file: File, filename?: string) => Promise<FormFileValue>;
884
+ /** Optional consumer render override, per field. */
885
+ fieldSlot?: (args: FieldSlotArgs) => JSX.Element;
886
+ /** Resolve the HTML autofill token for a field (or `undefined` to omit). */
887
+ autocomplete: (field: FormCoreField) => string | undefined;
888
+ }
889
+
890
+ export interface FieldSlotArgs {
891
+ field: FormCoreField;
892
+ value: unknown;
893
+ error: FieldError | undefined;
894
+ setValue: (value: unknown) => void;
895
+ }
896
+
897
+ function FieldRow(props: { field: FormCoreField; ctx: RenderCtx }): JSX.Element {
898
+ const field = () => props.field;
899
+ const value = () => props.ctx.snapshot().values[field().id];
900
+ const error = () => props.ctx.snapshot().errors[field().id];
901
+ const showLabel = () => !STATIC_NO_LABEL.has(field().type);
902
+
903
+ return (
904
+ <Show
905
+ when={!props.ctx.fieldSlot}
906
+ fallback={props.ctx.fieldSlot?.({
907
+ field: field(),
908
+ value: value(),
909
+ error: error(),
910
+ setValue: (v) => props.ctx.setValue(field().id, v),
911
+ })}
912
+ >
913
+ <div
914
+ data-field-id={field().id}
915
+ data-biab-field={fieldCssKey(field())}
916
+ data-biab-type={field().type}
917
+ class="biab-field"
918
+ classList={{
919
+ [`biab-field--${field().type}`]: true,
920
+ "biab-field--required": field().required === true,
921
+ "biab-field--error": Boolean(error()),
922
+ }}
923
+ >
924
+ <Show when={showLabel() && (field().label || field().required)}>
925
+ <label class="biab-field__label">
926
+ {field().label || ""}
927
+ <Show when={field().required}>
928
+ <span class="biab-field__required"> *</span>
929
+ </Show>
930
+ </label>
931
+ </Show>
932
+ <FieldInput
933
+ field={field()}
934
+ value={value}
935
+ set={(v) => props.ctx.setValue(field().id, v)}
936
+ uploadFile={(file, filename) =>
937
+ props.ctx.upload(field().id, file, filename)
938
+ }
939
+ uploading={() => props.ctx.snapshot().uploading}
940
+ autocomplete={props.ctx.autocomplete}
941
+ />
942
+ <Show when={error()}>
943
+ {(e) => <p class="biab-field__error">{e().message}</p>}
944
+ </Show>
945
+ <Show when={field().helpText}>
946
+ <p class="biab-field__help">{field().helpText}</p>
947
+ </Show>
948
+ </div>
949
+ </Show>
950
+ );
951
+ }
952
+
953
+ // ─────────────────────────────────────────────────────────────────────────────
954
+ // RenderTree — the recursive layout walk: input_group (+ concurrent reveal),
955
+ // condition_block, or_condition, flex_row, leaf fields. Mirrors FormPreview's
956
+ // `RenderTree` and uses the SAME `biab-*` hooks. `flat` drops the group border
957
+ // in consecutive mode (each step IS already a group).
958
+ // ─────────────────────────────────────────────────────────────────────────────
959
+
960
+ function RenderTree(props: {
961
+ fields: FormCoreField[];
962
+ ctx: RenderCtx;
963
+ flat?: boolean;
964
+ }): JSX.Element {
965
+ return (
966
+ <div class="biab-tree">
967
+ <For each={props.fields}>
968
+ {(node) => (
969
+ <Switch fallback={<FieldRow field={node} ctx={props.ctx} />}>
970
+ <Match when={node.type === "input_group"}>
971
+ {(() => {
972
+ const children = () => node.children ?? [];
973
+ const concurrent = () => node.concurrentReveal === true;
974
+ const revealCount = () =>
975
+ concurrent()
976
+ ? concurrentRevealCount(
977
+ children(),
978
+ props.ctx.snapshot().values,
979
+ )
980
+ : children().length;
981
+ return (
982
+ <div
983
+ data-biab-group={fieldCssKey(node)}
984
+ class="biab-group"
985
+ classList={{ "biab-group--bordered": !props.flat }}
986
+ >
987
+ <Show
988
+ when={node.label && node.showGroupLabel === true}
989
+ >
990
+ <div class="biab-group__label">{node.label}</div>
991
+ </Show>
992
+ <Show
993
+ when={concurrent()}
994
+ fallback={
995
+ <RenderTree fields={children()} ctx={props.ctx} />
996
+ }
997
+ >
998
+ <For each={children().slice(0, revealCount())}>
999
+ {(child) => (
1000
+ <div class="biab-group__reveal">
1001
+ <RenderTree
1002
+ fields={[child]}
1003
+ ctx={props.ctx}
1004
+ />
1005
+ </div>
1006
+ )}
1007
+ </For>
1008
+ </Show>
1009
+ </div>
1010
+ );
1011
+ })()}
1012
+ </Match>
1013
+
1014
+ <Match when={node.type === "condition_block"}>
1015
+ <Show
1016
+ when={evalCondition(
1017
+ node.conditionRule,
1018
+ props.ctx.snapshot().values,
1019
+ )}
1020
+ >
1021
+ <RenderTree fields={node.children ?? []} ctx={props.ctx} />
1022
+ </Show>
1023
+ </Match>
1024
+
1025
+ <Match when={node.type === "or_condition"}>
1026
+ {(() => {
1027
+ const left = () => node.children?.[0];
1028
+ const right = () => node.children?.[1];
1029
+ const errored = () =>
1030
+ Boolean(props.ctx.snapshot().errors[node.id]);
1031
+ const cfg = () =>
1032
+ (node.orConditionConfig ?? {}) as {
1033
+ leftLabel?: string;
1034
+ rightLabel?: string;
1035
+ };
1036
+ return (
1037
+ <div
1038
+ class="biab-or"
1039
+ classList={{ "biab-or--error": errored() }}
1040
+ >
1041
+ <Show when={node.label}>
1042
+ <div class="biab-or__label">
1043
+ {node.label}
1044
+ <span class="biab-or__hint">Fill at least one side</span>
1045
+ </div>
1046
+ </Show>
1047
+ <div class="biab-or__grid">
1048
+ <div class="biab-or__side">
1049
+ <div class="biab-or__side-label">
1050
+ {cfg().leftLabel || "Option A"}
1051
+ </div>
1052
+ <Show when={left()}>
1053
+ {(l) => (
1054
+ <RenderTree fields={[l()]} ctx={props.ctx} />
1055
+ )}
1056
+ </Show>
1057
+ </div>
1058
+ <div class="biab-or__divider">
1059
+ <span class="biab-or__badge">OR</span>
1060
+ </div>
1061
+ <div class="biab-or__side">
1062
+ <div class="biab-or__side-label">
1063
+ {cfg().rightLabel || "Option B"}
1064
+ </div>
1065
+ <Show when={right()}>
1066
+ {(r) => (
1067
+ <RenderTree fields={[r()]} ctx={props.ctx} />
1068
+ )}
1069
+ </Show>
1070
+ </div>
1071
+ </div>
1072
+ <Show when={errored()}>
1073
+ <p class="biab-or__error">
1074
+ Fill in at least one side to continue.
1075
+ </p>
1076
+ </Show>
1077
+ </div>
1078
+ );
1079
+ })()}
1080
+ </Match>
1081
+
1082
+ <Match when={node.type === "flex_row"}>
1083
+ <div class="biab-flex-row">
1084
+ <For each={node.children ?? []}>
1085
+ {(child) => (
1086
+ <div class="biab-flex-row__cell">
1087
+ <FieldRow field={child} ctx={props.ctx} />
1088
+ </div>
1089
+ )}
1090
+ </For>
1091
+ </div>
1092
+ </Match>
1093
+ </Switch>
1094
+ )}
1095
+ </For>
1096
+ </div>
1097
+ );
1098
+ }
1099
+
1100
+ // ─────────────────────────────────────────────────────────────────────────────
1101
+ // Progress — labeled "steps" or thin "bar". Mirrors FormPreview's two indicators.
1102
+ // ─────────────────────────────────────────────────────────────────────────────
1103
+
1104
+ function StepProgress(props: { snapshot: Accessor<FormSnapshot> }): JSX.Element {
1105
+ const steps = () => props.snapshot().steps;
1106
+ const current = () => props.snapshot().step;
1107
+ return (
1108
+ <div class="biab-progress biab-progress--steps">
1109
+ <div class="biab-progress__title">Form Progress</div>
1110
+ <div class="biab-progress__track">
1111
+ <For each={steps()}>
1112
+ {(s, i) => {
1113
+ const head = () => s[0];
1114
+ const title = () =>
1115
+ head()?.stepTitle || head()?.label || `Step ${i() + 1}`;
1116
+ const done = () => i() < current();
1117
+ const isCurrent = () => i() === current();
1118
+ return (
1119
+ <>
1120
+ <Show when={i() > 0}>
1121
+ <div
1122
+ class="biab-progress__line"
1123
+ classList={{
1124
+ "biab-progress__line--filled": i() <= current(),
1125
+ }}
1126
+ />
1127
+ </Show>
1128
+ <div class="biab-progress__node">
1129
+ <span
1130
+ class="biab-progress__dot"
1131
+ classList={{
1132
+ "biab-progress__dot--current": isCurrent(),
1133
+ "biab-progress__dot--done": done(),
1134
+ }}
1135
+ >
1136
+ {done() ? "✓" : i() + 1}
1137
+ </span>
1138
+ <span
1139
+ class="biab-progress__step-label"
1140
+ classList={{
1141
+ "biab-progress__step-label--current": isCurrent(),
1142
+ }}
1143
+ >
1144
+ {title()}
1145
+ </span>
1146
+ </div>
1147
+ </>
1148
+ );
1149
+ }}
1150
+ </For>
1151
+ </div>
1152
+ </div>
1153
+ );
1154
+ }
1155
+
1156
+ function BarProgress(props: { snapshot: Accessor<FormSnapshot> }): JSX.Element {
1157
+ return (
1158
+ <div class="biab-progress biab-progress--bar">
1159
+ <span class="biab-progress__count">
1160
+ {props.snapshot().step + 1} / {props.snapshot().stepCount}
1161
+ </span>
1162
+ <div class="biab-progress__bar-track">
1163
+ <div
1164
+ class="biab-progress__bar-fill"
1165
+ style={{ width: `${props.snapshot().progressPercent}%` }}
1166
+ />
1167
+ </div>
1168
+ </div>
1169
+ );
1170
+ }
1171
+
1172
+ // ─────────────────────────────────────────────────────────────────────────────
1173
+ // <BiabForm> — the drop-in component.
1174
+ // ─────────────────────────────────────────────────────────────────────────────
1175
+
1176
+ export interface BiabFormProps {
1177
+ /** Form slug — required unless a pre-fetched `schema` is supplied. */
1178
+ slug?: string;
1179
+ /** Pre-fetched schema (SSR / RSC). Adopted synchronously by the controller. */
1180
+ schema?: FormSchema;
1181
+ /** SDK client. Optional when a `<BiabProvider>` supplies one, or when only
1182
+ * rendering a pre-fetched `schema` without submitting. */
1183
+ client?: BiabClient;
1184
+ /** Submit-button text on the final step. */
1185
+ submitLabel?: string;
1186
+ /** Validate on the server but store nothing (no side effects). */
1187
+ dryRun?: boolean;
1188
+ /** Default submit options merged into every submit (call opts still win). */
1189
+ submitOptions?: FormSubmitOptions;
1190
+ /** Fired with the raw result on EVERY submit (success or failure). */
1191
+ onSubmit?: (result: FormSubmitResult) => void;
1192
+ /** Fired only on a successful submit. */
1193
+ onSuccess?: (result: Extract<FormSubmitResult, { ok: true }>) => void;
1194
+ /** Fired only on a failed submit. */
1195
+ onError?: (result: Extract<FormSubmitResult, { ok: false }>) => void;
1196
+ /** Per-field render override; return your own markup for any field. */
1197
+ field?: (args: FieldSlotArgs) => JSX.Element;
1198
+ /** Global HTML autofill switch. Defaults to inferring a token per field;
1199
+ * `false` emits `autocomplete="off"` on every input. Override individual
1200
+ * fields with `fieldAutoComplete`. */
1201
+ autoComplete?: boolean;
1202
+ /** Per-field autofill overrides keyed by field id: a literal token, `true`
1203
+ * (infer), or `false` (off). Wins over `autoComplete` and any schema token. */
1204
+ fieldAutoComplete?: Record<string, string | boolean>;
1205
+ /** Extra class on the form root (alias: `className`). */
1206
+ class?: string;
1207
+ className?: string;
1208
+ style?: JSX.CSSProperties | string;
1209
+ }
1210
+
1211
+ /**
1212
+ * `<BiabForm slug="contact" client={client} />` — a complete, themeable form for
1213
+ * a published BIAB form. Loads the schema, renders the field tree, validates
1214
+ * locally (mirroring the server), handles multi-step nav + progress + concurrent
1215
+ * reveal + file uploads, and submits through the SDK.
1216
+ *
1217
+ * All visual classes are `biab-*` on light DOM, so consumer CSS cascades. The
1218
+ * component ships no styles of its own.
1219
+ */
1220
+ export function BiabForm(props: BiabFormProps): JSX.Element {
1221
+ const providerClient = useBiabClient();
1222
+ const resolvedClient = () => props.client ?? providerClient;
1223
+ const formId = createUniqueId();
1224
+
1225
+ const store = createFormStore({
1226
+ ...(resolvedClient() ? { client: resolvedClient() } : {}),
1227
+ ...(props.slug ? { slug: props.slug } : {}),
1228
+ ...(props.schema ? { schema: props.schema } : {}),
1229
+ submitOptions: {
1230
+ ...props.submitOptions,
1231
+ ...(props.dryRun ? { dryRun: true } : {}),
1232
+ },
1233
+ onSuccess: (result) => {
1234
+ props.onSuccess?.(result);
1235
+ props.onSubmit?.(result);
1236
+ },
1237
+ onError: (result) => {
1238
+ props.onError?.(result);
1239
+ props.onSubmit?.(result);
1240
+ },
1241
+ });
1242
+
1243
+ const snap = store.snapshot;
1244
+ const settings = () => snap().settings;
1245
+ const isConsecutive = () => settings().isConsecutive;
1246
+ const multiStep = () => isConsecutive() && snap().stepCount > 1;
1247
+
1248
+ // Global + per-field HTML autofill config, built from props and resolved per
1249
+ // field at render time. See forms-core/autocomplete.ts.
1250
+ const autocompleteConfig = createMemo<AutocompleteConfig>(() => ({
1251
+ ...(props.autoComplete !== undefined ? { enabled: props.autoComplete } : {}),
1252
+ ...(props.fieldAutoComplete ? { overrides: props.fieldAutoComplete } : {}),
1253
+ }));
1254
+
1255
+ const ctx: RenderCtx = {
1256
+ snapshot: snap,
1257
+ setValue: (id, value) => store.controller.setValue(id, value),
1258
+ upload: (fieldId, file, filename) =>
1259
+ store.controller.uploadFile(fieldId, file, filename ? { filename } : undefined),
1260
+ autocomplete: (field) =>
1261
+ resolveFieldAutocomplete(field, autocompleteConfig()),
1262
+ ...(props.field ? { fieldSlot: props.field } : {}),
1263
+ };
1264
+
1265
+ const handleSubmit = (e: Event) => {
1266
+ e.preventDefault();
1267
+ void store.controller.submit();
1268
+ };
1269
+
1270
+ const rootClass = () =>
1271
+ ["biab-form", props.class, props.className].filter(Boolean).join(" ");
1272
+
1273
+ // Entrance-animation class for the active step (slide is directional).
1274
+ const animClass = () => {
1275
+ const a = settings().animation;
1276
+ const forward = snap().forward;
1277
+ switch (a) {
1278
+ case "fade":
1279
+ return "biab-anim biab-anim--fade";
1280
+ case "scale":
1281
+ return "biab-anim biab-anim--scale";
1282
+ case "slide":
1283
+ return forward
1284
+ ? "biab-anim biab-anim--slide-forward"
1285
+ : "biab-anim biab-anim--slide-back";
1286
+ case "scroll":
1287
+ return "biab-anim biab-anim--scroll";
1288
+ default:
1289
+ return "";
1290
+ }
1291
+ };
1292
+
1293
+ return (
1294
+ <Switch>
1295
+ {/* Loading / error / empty guards mirror the controller's status. */}
1296
+ <Match when={snap().status === "loading"}>
1297
+ <div class="biab-form biab-form--loading" aria-busy="true">
1298
+ Loading…
1299
+ </div>
1300
+ </Match>
1301
+ <Match when={snap().status === "error"}>
1302
+ <div class="biab-form biab-form--error" role="alert">
1303
+ {snap().loadError ?? "Failed to load the form."}
1304
+ </div>
1305
+ </Match>
1306
+ <Match when={snap().status === "ready"}>
1307
+ <form
1308
+ id={formId}
1309
+ class={rootClass()}
1310
+ style={props.style}
1311
+ onSubmit={handleSubmit}
1312
+ novalidate
1313
+ >
1314
+ {/* Org icon (showOrgIcon) — top-right corner mark. */}
1315
+ <Show when={snap().orgIcon}>
1316
+ {(icon) => (
1317
+ <img class="biab-form__org-icon" src={icon()} alt="" />
1318
+ )}
1319
+ </Show>
1320
+
1321
+ {/* Title / description from the schema. */}
1322
+ <Show when={snap().title || snap().description}>
1323
+ <div class="biab-form__header">
1324
+ <Show when={snap().title}>
1325
+ <h2 class="biab-form__title">{snap().title}</h2>
1326
+ </Show>
1327
+ <Show when={snap().description}>
1328
+ <p class="biab-form__description">{snap().description}</p>
1329
+ </Show>
1330
+ </div>
1331
+ </Show>
1332
+
1333
+ {/* Multi-step progress indicator. */}
1334
+ <Show when={multiStep() && settings().showProgress}>
1335
+ <Show
1336
+ when={settings().progressStyle === "bar"}
1337
+ fallback={<StepProgress snapshot={snap} />}
1338
+ >
1339
+ <BarProgress snapshot={snap} />
1340
+ </Show>
1341
+ </Show>
1342
+
1343
+ {/* Form-level error summary. */}
1344
+ <Show when={snap().errorList.length > 0}>
1345
+ <div class="biab-form__errors" role="alert">
1346
+ <div class="biab-form__errors-title">
1347
+ Please complete the highlighted required field
1348
+ {snap().errorList.length === 1 ? "" : "s"}.
1349
+ </div>
1350
+ <div class="biab-form__errors-list">
1351
+ {snap()
1352
+ .errorList.map((er) => er.label)
1353
+ .join(", ")}
1354
+ </div>
1355
+ </div>
1356
+ </Show>
1357
+
1358
+ {/* The field tree for the active step (single-page = all fields).
1359
+ Keyed on step index so the entrance animation re-runs per step. */}
1360
+ <Show
1361
+ when={settings().animation === "scroll"}
1362
+ fallback={
1363
+ <div class={animClass()} data-step={snap().step}>
1364
+ <RenderTree
1365
+ fields={snap().visibleFields}
1366
+ ctx={ctx}
1367
+ flat={isConsecutive()}
1368
+ />
1369
+ </div>
1370
+ }
1371
+ >
1372
+ <div class="biab-form__scroll">
1373
+ <For each={snap().visibleFields}>
1374
+ {(vf) => (
1375
+ <div class="biab-anim biab-anim--scroll">
1376
+ <RenderTree
1377
+ fields={[vf]}
1378
+ ctx={ctx}
1379
+ flat={isConsecutive()}
1380
+ />
1381
+ </div>
1382
+ )}
1383
+ </For>
1384
+ </div>
1385
+ </Show>
1386
+
1387
+ {/* Navigation / submit. */}
1388
+ <Show
1389
+ when={multiStep()}
1390
+ fallback={
1391
+ <div class="biab-nav">
1392
+ <button
1393
+ type="submit"
1394
+ class="biab-btn biab-btn--submit"
1395
+ disabled={snap().submitting}
1396
+ >
1397
+ {snap().submitting
1398
+ ? "Submitting…"
1399
+ : (props.submitLabel ?? "Submit")}
1400
+ </button>
1401
+ </div>
1402
+ }
1403
+ >
1404
+ <div class="biab-nav biab-nav--steps">
1405
+ <button
1406
+ type="button"
1407
+ class="biab-btn biab-btn--back"
1408
+ disabled={snap().isFirstStep}
1409
+ onClick={() => store.controller.back()}
1410
+ >
1411
+ Back
1412
+ </button>
1413
+ <Show
1414
+ when={snap().isLastStep}
1415
+ fallback={
1416
+ <button
1417
+ type="button"
1418
+ class="biab-btn biab-btn--next"
1419
+ disabled={!snap().canAdvance}
1420
+ onClick={() => store.controller.next()}
1421
+ >
1422
+ Next
1423
+ </button>
1424
+ }
1425
+ >
1426
+ <button
1427
+ type="submit"
1428
+ class="biab-btn biab-btn--submit"
1429
+ disabled={snap().submitting}
1430
+ >
1431
+ {snap().submitting
1432
+ ? "Submitting…"
1433
+ : (props.submitLabel ?? "Submit")}
1434
+ </button>
1435
+ </Show>
1436
+ </div>
1437
+ </Show>
1438
+
1439
+ {/* Success state. */}
1440
+ <Show when={snap().submitted}>
1441
+ <div class="biab-form__success" role="status">
1442
+ Thanks — your response was received.
1443
+ </div>
1444
+ </Show>
1445
+ </form>
1446
+ </Match>
1447
+ </Switch>
1448
+ );
1449
+ }
1450
+
1451
+ // Re-export the core types a consumer commonly annotates against, so they can
1452
+ // import everything they need from `@businessdash/sdk/solid` alone.
1453
+ export type {
1454
+ BiabClient,
1455
+ FormSchema,
1456
+ FormSubmitResult,
1457
+ FormFileValue,
1458
+ } from "./sdk.js";
1459
+ export type {
1460
+ FieldError,
1461
+ FormController,
1462
+ FormCoreField,
1463
+ FormSnapshot,
1464
+ FormSubmitOptions,
1465
+ } from "./forms-core/index.js";
1466
+ export { createFormController } from "./forms-core/index.js";