@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
@@ -0,0 +1,1069 @@
1
+ <!--
2
+ @businessdash/sdk/svelte — <BiabForm> drop-in (Svelte 5, runes).
3
+
4
+ Renders a BIAB form end-to-end from the headless core
5
+ (`createFormController` in `@businessdash/sdk/forms`). It is a faithful port of
6
+ the dashboard `FormPreview` renderer: per-type field rendering, the
7
+ condition_block / or_condition / input_group / flex_row tree walk,
8
+ multi-step ("consecutive") navigation + progress (bar | steps),
9
+ concurrent-reveal gating, and the schema's render settings
10
+ (animation, org icon). All markup uses the `biab-*` light-DOM class
11
+ convention so the consumer's CSS cascades — no shadow root.
12
+
13
+ Two data paths (consistent with the other framework bindings):
14
+
15
+ • client mode — pass a `BiabClient` (prop or `setBiabClient` context).
16
+ The controller fetches the schema (when only `slug` is given) and
17
+ submits / uploads through the SDK directly. Only safe where the bearer
18
+ key never reaches the browser.
19
+
20
+ • schema + submitAction mode — pass a pre-fetched `schema` (SSR-loaded
21
+ via your server `BiabClient`) plus a `submitAction(payload, opts)` that
22
+ POSTs to your own endpoint (e.g. SvelteKit `/api/biab/forms/[slug]`).
23
+ The bearer key stays in `$lib/server`. This is the recommended
24
+ SvelteKit pattern and the one the starter wires up.
25
+ -->
26
+ <script lang="ts" module>
27
+ import type { BiabClient } from "../sdk.js";
28
+ import type {
29
+ FormCoreField,
30
+ FormSnapshot,
31
+ FormSubmitOptions,
32
+ FormSubmitResult,
33
+ FormSchema,
34
+ } from "../forms-core/index.js";
35
+
36
+ /** A successful submit result (`ok === true`). */
37
+ export type BiabFormSuccess = Extract<FormSubmitResult, { ok: true }>;
38
+ /** A failed submit result (`ok === false`). */
39
+ export type BiabFormFailure = Extract<FormSubmitResult, { ok: false }>;
40
+
41
+ /**
42
+ * Re-key the working data (by field `id`) to the published output `key`,
43
+ * then POST it to your own endpoint. Returning a {@link FormSubmitResult}
44
+ * lets `<BiabForm>` surface inline server-side field errors; returning
45
+ * void/anything else is treated as a generic success.
46
+ */
47
+ export type BiabFormSubmitAction = (
48
+ payload: Record<string, unknown>,
49
+ options: FormSubmitOptions,
50
+ ) => Promise<FormSubmitResult | void> | FormSubmitResult | void;
51
+
52
+ export interface BiabFormProps {
53
+ /** Form slug — required in client mode (or when `schema` lacks a slug). */
54
+ slug?: string;
55
+ /** Pre-fetched schema (SSR). When present, no client is needed to render. */
56
+ schema?: FormSchema;
57
+ /** SDK client. Falls back to `setBiabClient` context. Omit in schema+action mode. */
58
+ client?: BiabClient;
59
+ /**
60
+ * Submit override. When provided, `<BiabForm>` validates locally, re-keys
61
+ * the payload to output keys, then calls this instead of the SDK — the
62
+ * server-safe SvelteKit path. Takes precedence over `client.forms.submit`.
63
+ */
64
+ submitAction?: BiabFormSubmitAction;
65
+ /** Submit button label (final step). Default "Submit". */
66
+ submitLabel?: string;
67
+ /** Merged into every submit (e.g. `source`, `metadata`). */
68
+ submitOptions?: FormSubmitOptions;
69
+ /** Server-validates but stores nothing. */
70
+ dryRun?: boolean;
71
+ /** Fired after a successful submit. */
72
+ onSuccess?: (result: BiabFormSuccess) => void;
73
+ /** Fired after a failed submit. */
74
+ onError?: (result: BiabFormFailure) => void;
75
+ /** Convenience: fired with the re-keyed payload on a successful submit. */
76
+ onSubmit?: (payload: Record<string, unknown>) => void;
77
+ /** Extra class on the root `.biab-form`. (`className` alias also accepted.) */
78
+ class?: string;
79
+ /** React-style alias for `class`. */
80
+ className?: string;
81
+ /** Message shown after a successful submit (when the server gives none). */
82
+ successMessage?: string;
83
+ /**
84
+ * Global HTML autofill switch. `false` emits `autocomplete="off"` on every
85
+ * value-bearing input; omit (default) to infer a sensible token per field.
86
+ */
87
+ autoComplete?: boolean;
88
+ /**
89
+ * Per-field autofill overrides keyed by field id: a literal token,
90
+ * `true` (infer), or `false` (off). Wins over `autoComplete` and any
91
+ * schema-level token.
92
+ */
93
+ fieldAutoComplete?: Record<string, string | boolean>;
94
+ }
95
+ </script>
96
+
97
+ <script lang="ts">
98
+ import { onDestroy, untrack } from "svelte";
99
+
100
+ import {
101
+ concurrentRevealCount,
102
+ createFormController,
103
+ evalCondition,
104
+ fetchIconNames,
105
+ filterIconNames,
106
+ iconUrl,
107
+ shouldSearchOptions,
108
+ toSubmissionPayload,
109
+ } from "../forms-core/index.js";
110
+ import {
111
+ type AutocompleteConfig,
112
+ resolveFieldAutocomplete,
113
+ } from "../forms-core/autocomplete.js";
114
+
115
+ import { formStore, getBiabClient } from "./context.js";
116
+
117
+ let {
118
+ slug,
119
+ schema,
120
+ client,
121
+ submitAction,
122
+ submitLabel = "Submit",
123
+ submitOptions,
124
+ dryRun = false,
125
+ onSuccess,
126
+ onError,
127
+ onSubmit,
128
+ class: klass,
129
+ className,
130
+ successMessage = "Thanks — your response was received.",
131
+ autoComplete,
132
+ fieldAutoComplete,
133
+ }: BiabFormProps = $props();
134
+
135
+ // HTML autofill config threaded to every value-bearing input. `enabled` only
136
+ // when the global switch is set; `overrides` only when per-field tokens exist
137
+ // — mirrors the React adapter so unset props fall through to inference.
138
+ const autocompleteConfig = $derived<AutocompleteConfig>({
139
+ ...(autoComplete !== undefined ? { enabled: autoComplete } : {}),
140
+ ...(fieldAutoComplete ? { overrides: fieldAutoComplete } : {}),
141
+ });
142
+
143
+ // The controller is built ONCE from the initial props (a form's identity —
144
+ // slug / schema / client — doesn't change mid-mount, and re-creating it would
145
+ // discard the visitor's in-progress input). `untrack` reads the init-time prop
146
+ // values without registering a reactive dependency, which also silences
147
+ // Svelte's `state_referenced_locally` hint for this deliberate one-time read.
148
+ const init = untrack(() => ({
149
+ client: client ?? getBiabClient(),
150
+ slug,
151
+ schema,
152
+ submitOptions: { ...submitOptions, ...(dryRun ? { dryRun: true } : {}) },
153
+ }));
154
+
155
+ /** True when an SDK client is available (gates SDK-driven file uploads). */
156
+ const hasClient = Boolean(init.client);
157
+
158
+ // ── Build the controller once. It owns ALL runtime state; we just render. ──
159
+ const controller = createFormController({
160
+ client: init.client,
161
+ slug: init.slug,
162
+ schema: init.schema,
163
+ submitOptions: init.submitOptions,
164
+ onSuccess: (result) => {
165
+ onSubmit?.(toSubmissionPayload(controller.snapshot().fields, controller.snapshot().values));
166
+ onSuccess?.(result);
167
+ },
168
+ onError: (result) => onError?.(result),
169
+ });
170
+
171
+ // Kick the load (fetches the schema in client mode, or adopts the SSR one).
172
+ void controller.load();
173
+ onDestroy(() => controller.destroy());
174
+
175
+ // The single reactive source of truth for the markup.
176
+ const snap = formStore(controller);
177
+
178
+ const rootClass = $derived(
179
+ ["biab-form", klass, className].filter(Boolean).join(" "),
180
+ );
181
+
182
+ // ── Submit: route through `submitAction` (server-safe) or the SDK. ─────────
183
+ async function handleSubmit(): Promise<void> {
184
+ if (submitAction) {
185
+ // Local-validate first; the controller publishes errors + a failure result.
186
+ const missing = controller.validate();
187
+ if (missing.length > 0) return;
188
+ const s = controller.snapshot();
189
+ const payload = toSubmissionPayload(s.fields, s.values);
190
+ const opts: FormSubmitOptions = {
191
+ ...submitOptions,
192
+ ...(dryRun ? { dryRun: true } : {}),
193
+ };
194
+ try {
195
+ submittingViaAction = true;
196
+ const res = await submitAction(payload, opts);
197
+ submittingViaAction = false;
198
+ // A returned FormSubmitResult lets us mirror server-side field errors.
199
+ if (res && typeof res === "object" && "ok" in res) {
200
+ actionResult = res;
201
+ if (res.ok) {
202
+ onSubmit?.(payload);
203
+ onSuccess?.(res);
204
+ } else {
205
+ onError?.(res);
206
+ }
207
+ return;
208
+ }
209
+ // No structured result → treat as success.
210
+ const synthetic: BiabFormSuccess = {
211
+ ok: true,
212
+ status: 200,
213
+ submissionId: "",
214
+ } as BiabFormSuccess;
215
+ actionResult = synthetic;
216
+ onSubmit?.(payload);
217
+ onSuccess?.(synthetic);
218
+ } catch (err) {
219
+ submittingViaAction = false;
220
+ const failure: BiabFormFailure = {
221
+ ok: false,
222
+ status: 0,
223
+ reason: "network_error",
224
+ message:
225
+ err instanceof Error ? err.message : "The submission failed.",
226
+ } as BiabFormFailure;
227
+ actionResult = failure;
228
+ onError?.(failure);
229
+ }
230
+ return;
231
+ }
232
+ // SDK path: the controller validates + submits and notifies the store.
233
+ await controller.submit(dryRun ? { dryRun: true } : undefined);
234
+ }
235
+
236
+ // `submitAction` mode tracks its own submitting/result locally (the controller
237
+ // never makes the network call in that mode).
238
+ let submittingViaAction = $state(false);
239
+ let actionResult = $state<FormSubmitResult | null>(null);
240
+
241
+ const submitting = $derived($snap.submitting || submittingViaAction);
242
+ const submitted = $derived(
243
+ $snap.submitted || actionResult?.ok === true,
244
+ );
245
+ const submitError = $derived(
246
+ actionResult && !actionResult.ok
247
+ ? actionResult.message
248
+ : $snap.result && !$snap.result.ok
249
+ ? $snap.result.message
250
+ : null,
251
+ );
252
+
253
+ function onFieldInput(id: string, value: unknown): void {
254
+ controller.setValue(id, value);
255
+ }
256
+
257
+ // ── File uploads (SDK client mode only) ────────────────────────────────────
258
+ async function onFilePick(field: FormCoreField, files: FileList | null): Promise<void> {
259
+ if (!files || files.length === 0) return;
260
+ const max = Math.min(
261
+ field.fileConfig?.maxFiles ?? files.length,
262
+ files.length,
263
+ );
264
+ for (let i = 0; i < max; i++) {
265
+ const file = files[i];
266
+ if (!file) continue;
267
+ try {
268
+ await controller.uploadFile(field.id, file, { filename: file.name });
269
+ } catch (err) {
270
+ uploadError = err instanceof Error ? err.message : "Upload failed.";
271
+ }
272
+ }
273
+ }
274
+ let uploadError = $state<string | null>(null);
275
+
276
+ /** `image_input`: upload one image, then store its URL as the field's value —
277
+ * the submitted value is a plain URL string whichever source was used. */
278
+ async function onImagePick(
279
+ field: FormCoreField,
280
+ files: FileList | null,
281
+ maxSizeMb: number,
282
+ ): Promise<void> {
283
+ const file = files?.[0];
284
+ if (!file) return;
285
+ if (file.size > maxSizeMb * 1024 * 1024) {
286
+ uploadError = `That image is over the ${maxSizeMb}MB limit.`;
287
+ return;
288
+ }
289
+ try {
290
+ const uploaded = await controller.uploadFile(field.id, file, {
291
+ filename: file.name,
292
+ });
293
+ const url = (uploaded as { url?: string } | undefined)?.url;
294
+ if (url) controller.setValue(field.id, url);
295
+ } catch (err) {
296
+ uploadError = err instanceof Error ? err.message : "Upload failed.";
297
+ }
298
+ }
299
+
300
+ // ── icon_select ───────────────────────────────────────────────────────────
301
+ // The value is an icon NAME. Art resolves against the platform's icon route
302
+ // rather than bundling an icon set, and the name list is fetched lazily (it's
303
+ // ~1,600 entries that change only when Lucide does). A curated
304
+ // `iconConfig.allowedIcons` skips the fetch entirely.
305
+ let iconQuery = $state("");
306
+ let iconIndex = $state<string[]>([]);
307
+ let iconIndexRequested = false;
308
+
309
+ function iconPool(field: FormCoreField): string[] {
310
+ const curated = field.iconConfig?.allowedIcons ?? [];
311
+ if (curated.length > 0) return curated;
312
+ if (!iconIndexRequested) {
313
+ iconIndexRequested = true;
314
+ // Resolves to [] on failure rather than throwing.
315
+ void fetchIconNames().then((names) => {
316
+ iconIndex = names;
317
+ });
318
+ }
319
+ return iconIndex;
320
+ }
321
+
322
+ let imageMode = $state<"upload" | "url">("upload");
323
+
324
+ // ── Render helpers (ported from FormPreview, framework-free) ───────────────
325
+
326
+ /** Stable CSS hook for a field (prefers output key, then label slug, then id). */
327
+ function fieldCssKey(f: FormCoreField): string {
328
+ const explicit =
329
+ (typeof f.outputKey === "string" && f.outputKey.trim()) ||
330
+ (typeof f.key === "string" && f.key.trim());
331
+ if (explicit) return explicit;
332
+ const lbl = typeof f.label === "string" ? f.label.trim() : "";
333
+ return lbl ? slugifyKey(lbl) : f.id;
334
+ }
335
+
336
+ function slugifyKey(raw: string): string {
337
+ const words = raw
338
+ .replace(/[^a-zA-Z0-9]+/g, " ")
339
+ .trim()
340
+ .split(/\s+/)
341
+ .filter(Boolean);
342
+ if (words.length === 0) return "";
343
+ const key = words
344
+ .map((w, i) =>
345
+ i === 0
346
+ ? w.toLowerCase()
347
+ : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(),
348
+ )
349
+ .join("");
350
+ return /^[0-9]/.test(key) ? `_${key}` : key;
351
+ }
352
+
353
+ const LABELLESS = new Set(["title", "subtitle", "separator", "richtext"]);
354
+
355
+ /** tw-animate entrance classes for a step (directional for "slide"). */
356
+ function entranceClass(animation: string, forward: boolean): string {
357
+ switch (animation) {
358
+ case "fade":
359
+ return "animate-in fade-in duration-500";
360
+ case "scale":
361
+ return "animate-in fade-in zoom-in-95 duration-500";
362
+ case "slide":
363
+ return forward
364
+ ? "animate-in fade-in slide-in-from-right-8 duration-500"
365
+ : "animate-in fade-in slide-in-from-left-8 duration-500";
366
+ default:
367
+ return "";
368
+ }
369
+ }
370
+
371
+ function timeOptions(stepMinutes: number): string[] {
372
+ const step = Math.max(stepMinutes, 1);
373
+ const opts: string[] = [];
374
+ for (let h = 0; h < 24; h++) {
375
+ for (let m = 0; m < 60; m += step) {
376
+ opts.push(`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`);
377
+ }
378
+ }
379
+ return opts;
380
+ }
381
+
382
+ function fmtTime(t: string, format: "12h" | "24h"): string {
383
+ if (format === "24h") return t;
384
+ const [hStr, mStr = "00"] = t.split(":");
385
+ const h = Number(hStr);
386
+ const ampm = h < 12 ? "AM" : "PM";
387
+ const h12 = h % 12 === 0 ? 12 : h % 12;
388
+ return `${h12}:${mStr} ${ampm}`;
389
+ }
390
+
391
+ function asObj(v: unknown): Record<string, unknown> {
392
+ return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
393
+ }
394
+ function asStr(v: unknown): string {
395
+ return typeof v === "string" ? v : "";
396
+ }
397
+ function asNum(v: unknown, fallback: number): number {
398
+ return typeof v === "number" ? v : fallback;
399
+ }
400
+
401
+ /** Children of a `condition_block` whose rule passes; else `null`. */
402
+ function conditionChildren(
403
+ f: FormCoreField,
404
+ values: Record<string, unknown>,
405
+ ): FormCoreField[] | null {
406
+ if (!evalCondition(f.conditionRule, values)) return null;
407
+ return (f.children ?? []) as FormCoreField[];
408
+ }
409
+
410
+ /** How many leading children of a concurrent group to reveal. */
411
+ function revealCountFor(
412
+ f: FormCoreField,
413
+ values: Record<string, unknown>,
414
+ ): number {
415
+ const kids = (f.children ?? []) as FormCoreField[];
416
+ return f.concurrentReveal === true
417
+ ? concurrentRevealCount(kids, values)
418
+ : kids.length;
419
+ }
420
+ </script>
421
+
422
+ {#snippet fieldInput(field: FormCoreField, snapshot: FormSnapshot)}
423
+ {@const value = snapshot.values[field.id]}
424
+ {@const ac = resolveFieldAutocomplete(field, autocompleteConfig)}
425
+ {@const acOff = ac === "off"}
426
+ {#if field.type === "title"}
427
+ <h2 class="biab-field__title">{field.label || "Untitled"}</h2>
428
+ {:else if field.type === "subtitle"}
429
+ <h3 class="biab-field__subtitle">{field.label || ""}</h3>
430
+ {:else if field.type === "separator"}
431
+ <hr class="biab-field__separator" />
432
+ {:else if field.type === "richtext"}
433
+ <!-- richtext is pre-sanitized server-side; rendered as-is. -->
434
+ <div class="biab-field__richtext">{@html field.content ?? ""}</div>
435
+ {:else if field.type === "image"}
436
+ {#if asStr(field.content)}
437
+ <img class="biab-field__image" src={asStr(field.content)} alt={field.label ?? ""} />
438
+ {/if}
439
+ {:else if field.type === "textarea"}
440
+ {@const taRows = (field.textareaConfig as { rows?: number } | undefined)?.rows ?? 4}
441
+ <textarea
442
+ class="biab-textarea biab-field__control"
443
+ rows={taRows}
444
+ placeholder={field.placeholder ?? ""}
445
+ autocomplete={ac ?? undefined}
446
+ value={asStr(value)}
447
+ oninput={(e) => onFieldInput(field.id, e.currentTarget.value)}
448
+ ></textarea>
449
+ {:else if field.type === "select" && shouldSearchOptions((field.options ?? []).length, field.display)}
450
+ <!-- ComboBox card, or a list long enough that scrolling it is the worse
451
+ default. `input list` + `datalist` is native typeahead: no JS, no
452
+ styling opinion, which is the SDK's contract. -->
453
+ <input
454
+ class="biab-input biab-combobox biab-field__control"
455
+ type="text"
456
+ list={`biab-list-${field.id}`}
457
+ placeholder={field.placeholder || "Search…"}
458
+ value={asStr(value)}
459
+ oninput={(e) => onFieldInput(field.id, e.currentTarget.value)}
460
+ />
461
+ <datalist id={`biab-list-${field.id}`}>
462
+ {#each field.options ?? [] as opt (opt)}
463
+ <option value={opt}></option>
464
+ {/each}
465
+ </datalist>
466
+ {:else if field.type === "select"}
467
+ <select
468
+ class="biab-select biab-field__control"
469
+ value={asStr(value)}
470
+ onchange={(e) => onFieldInput(field.id, e.currentTarget.value)}
471
+ >
472
+ <option value="">{field.placeholder || "Select…"}</option>
473
+ {#each field.options ?? [] as opt (opt)}
474
+ <option value={opt}>{opt}</option>
475
+ {/each}
476
+ </select>
477
+ {:else if field.type === "icon_select"}
478
+ <!-- The value is an icon NAME; art resolves against the platform's icon
479
+ route, so no icon library ships in the SDK bundle. -->
480
+ {@const names = iconPool(field)}
481
+ <div class="biab-iconselect">
482
+ <input
483
+ class="biab-input biab-iconselect__search"
484
+ type="text"
485
+ placeholder={field.placeholder || "Search icons…"}
486
+ value={iconQuery}
487
+ oninput={(e) => (iconQuery = e.currentTarget.value)}
488
+ />
489
+ {#if asStr(value)}
490
+ <span class="biab-iconselect__current">
491
+ <img src={iconUrl(asStr(value))} alt={asStr(value)} width="16" height="16" />
492
+ <button type="button" class="biab-iconselect__clear" onclick={() => onFieldInput(field.id, "")}>×</button>
493
+ </span>
494
+ {/if}
495
+ <div class="biab-iconselect__grid">
496
+ {#each filterIconNames(names, iconQuery) as name (name)}
497
+ <button
498
+ type="button"
499
+ class="biab-iconselect__item"
500
+ class:is-selected={asStr(value) === name}
501
+ title={name}
502
+ aria-label={name}
503
+ aria-pressed={asStr(value) === name}
504
+ onclick={() => onFieldInput(field.id, name)}
505
+ >
506
+ <img src={iconUrl(name)} alt="" width="16" height="16" />
507
+ </button>
508
+ {/each}
509
+ </div>
510
+ </div>
511
+ {:else if field.type === "image_input"}
512
+ <!-- One image, either source; the submitted value is a plain URL string. -->
513
+ {@const cfg = field.imageInputConfig ?? {}}
514
+ {@const sources = cfg.sources ?? "both"}
515
+ <div class="biab-imageinput">
516
+ {#if sources === "both"}
517
+ <div class="biab-imageinput__modes">
518
+ <button
519
+ type="button"
520
+ class="biab-imageinput__mode"
521
+ class:is-active={imageMode === "upload"}
522
+ aria-pressed={imageMode === "upload"}
523
+ onclick={() => (imageMode = "upload")}>Upload</button
524
+ >
525
+ <button
526
+ type="button"
527
+ class="biab-imageinput__mode"
528
+ class:is-active={imageMode === "url"}
529
+ aria-pressed={imageMode === "url"}
530
+ onclick={() => (imageMode = "url")}>Paste URL</button
531
+ >
532
+ </div>
533
+ {/if}
534
+ {#if imageMode === "upload" && sources !== "url" && hasClient}
535
+ <input
536
+ class="biab-input biab-imageinput__file"
537
+ type="file"
538
+ accept={(cfg.allowedTypes ?? ["image/*"]).join(",")}
539
+ onchange={(e) => onImagePick(field, e.currentTarget.files, cfg.maxSize ?? 5)}
540
+ />
541
+ {:else}
542
+ <input
543
+ class="biab-input biab-imageinput__url"
544
+ type="url"
545
+ placeholder={field.placeholder || "https://example.com/image.png"}
546
+ value={asStr(value)}
547
+ oninput={(e) => onFieldInput(field.id, e.currentTarget.value)}
548
+ />
549
+ {/if}
550
+ {#if cfg.showPreview !== false && asStr(value)}
551
+ <img class="biab-imageinput__preview" src={asStr(value)} alt="" />
552
+ {/if}
553
+ </div>
554
+ {:else if field.type === "radio"}
555
+ <div class="biab-field__radios">
556
+ {#each field.options ?? [] as opt (opt)}
557
+ <label class="biab-field__radio">
558
+ <input
559
+ type="radio"
560
+ name={field.id}
561
+ value={opt}
562
+ checked={asStr(value) === opt}
563
+ onchange={() => onFieldInput(field.id, opt)}
564
+ />
565
+ <span>{opt}</span>
566
+ </label>
567
+ {/each}
568
+ </div>
569
+ {:else if field.type === "checkbox"}
570
+ {@const arr = Array.isArray(value) ? (value as string[]) : []}
571
+ <div class="biab-field__checks">
572
+ {#each field.options ?? [] as opt (opt)}
573
+ <label class="biab-field__check">
574
+ <input
575
+ type="checkbox"
576
+ checked={arr.includes(opt)}
577
+ onchange={(e) =>
578
+ onFieldInput(
579
+ field.id,
580
+ e.currentTarget.checked
581
+ ? [...arr, opt]
582
+ : arr.filter((x) => x !== opt),
583
+ )}
584
+ />
585
+ <span>{opt}</span>
586
+ </label>
587
+ {/each}
588
+ </div>
589
+ {:else if field.type === "boolean"}
590
+ <label class="biab-field__toggle">
591
+ <input
592
+ type="checkbox"
593
+ checked={value === true}
594
+ onchange={(e) => onFieldInput(field.id, e.currentTarget.checked)}
595
+ />
596
+ <span>{field.placeholder || "Yes / No"}</span>
597
+ </label>
598
+ {:else if field.type === "date"}
599
+ <input
600
+ class="biab-input biab-field__control"
601
+ type="date"
602
+ value={asStr(value)}
603
+ onchange={(e) => onFieldInput(field.id, e.currentTarget.value)}
604
+ />
605
+ {:else if field.type === "time_picker"}
606
+ {@const mode = field.timeConfig?.mode ?? "simple"}
607
+ {#if mode === "simple"}
608
+ <select
609
+ class="biab-select biab-field__control"
610
+ value={asStr(value)}
611
+ onchange={(e) => onFieldInput(field.id, e.currentTarget.value)}
612
+ >
613
+ <option value="">{field.placeholder || "Select time"}</option>
614
+ {#each timeOptions(field.timeConfig?.stepMinutes ?? 30) as o (o)}
615
+ <option value={o}>{fmtTime(o, field.timeConfig?.format ?? "12h")}</option>
616
+ {/each}
617
+ </select>
618
+ {:else if mode === "time_block"}
619
+ {@const v = asObj(value)}
620
+ <div class="biab-field__grid2">
621
+ <label class="biab-field__sub">
622
+ <span class="biab-field__sublabel">Start</span>
623
+ <input
624
+ class="biab-input"
625
+ type="time"
626
+ value={asStr(v.start)}
627
+ onchange={(e) =>
628
+ onFieldInput(field.id, { ...v, start: e.currentTarget.value })}
629
+ />
630
+ </label>
631
+ <label class="biab-field__sub">
632
+ <span class="biab-field__sublabel">End</span>
633
+ <input
634
+ class="biab-input"
635
+ type="time"
636
+ value={asStr(v.end)}
637
+ onchange={(e) =>
638
+ onFieldInput(field.id, { ...v, end: e.currentTarget.value })}
639
+ />
640
+ </label>
641
+ </div>
642
+ {:else}
643
+ {@const v = asObj(value)}
644
+ <div class="biab-field__grid2">
645
+ <label class="biab-field__sub">
646
+ <span class="biab-field__sublabel">Date</span>
647
+ <input
648
+ class="biab-input"
649
+ type="date"
650
+ value={asStr(v.date)}
651
+ onchange={(e) =>
652
+ onFieldInput(field.id, { ...v, date: e.currentTarget.value })}
653
+ />
654
+ </label>
655
+ <label class="biab-field__sub">
656
+ <span class="biab-field__sublabel">Time</span>
657
+ <input
658
+ class="biab-input"
659
+ type="time"
660
+ value={asStr(v.time)}
661
+ onchange={(e) =>
662
+ onFieldInput(field.id, { ...v, time: e.currentTarget.value })}
663
+ />
664
+ </label>
665
+ </div>
666
+ {/if}
667
+ {:else if field.type === "date_range"}
668
+ {@const v = asObj(value)}
669
+ <div class="biab-field__grid2">
670
+ <label class="biab-field__sub">
671
+ <span class="biab-field__sublabel">Start</span>
672
+ <input
673
+ class="biab-input"
674
+ type="date"
675
+ value={asStr(v.start)}
676
+ onchange={(e) =>
677
+ onFieldInput(field.id, { ...v, start: e.currentTarget.value })}
678
+ />
679
+ </label>
680
+ <label class="biab-field__sub">
681
+ <span class="biab-field__sublabel">End</span>
682
+ <input
683
+ class="biab-input"
684
+ type="date"
685
+ value={asStr(v.end)}
686
+ onchange={(e) =>
687
+ onFieldInput(field.id, { ...v, end: e.currentTarget.value })}
688
+ />
689
+ </label>
690
+ </div>
691
+ {:else if field.type === "slider"}
692
+ {@const min = field.sliderConfig?.min ?? 0}
693
+ {@const max = field.sliderConfig?.max ?? 100}
694
+ {@const step = field.sliderConfig?.step ?? 1}
695
+ {@const v = asNum(value, min)}
696
+ <div class="biab-field__slider">
697
+ <div class="biab-field__slider-meta">
698
+ <span>{min}</span><span>{v}</span><span>{max}</span>
699
+ </div>
700
+ <input
701
+ type="range"
702
+ {min}
703
+ {max}
704
+ {step}
705
+ value={v}
706
+ oninput={(e) => onFieldInput(field.id, Number(e.currentTarget.value))}
707
+ />
708
+ </div>
709
+ {:else if field.type === "star_rating"}
710
+ {@const max = field.starRatingConfig?.maxStars ?? 5}
711
+ {@const v = asNum(value, 0)}
712
+ {@const stars = Array.from({ length: max }, (_unused, i) => i)}
713
+ <div class="biab-field__stars">
714
+ {#each stars as i (i)}
715
+ <button
716
+ type="button"
717
+ class="biab-field__star"
718
+ class:biab-field__star--on={i < v}
719
+ aria-label={`Rate ${i + 1}`}
720
+ onclick={() => onFieldInput(field.id, i + 1)}
721
+ >
722
+
723
+ </button>
724
+ {/each}
725
+ </div>
726
+ {:else if field.type === "happiness_rating"}
727
+ {@const v = asNum(value, -1)}
728
+ <div class="biab-field__happiness">
729
+ {#each ["😞", "😕", "😐", "🙂", "😄"] as emoji, i (emoji)}
730
+ <button
731
+ type="button"
732
+ class="biab-field__emoji"
733
+ class:biab-field__emoji--on={i === v}
734
+ onclick={() => onFieldInput(field.id, i)}
735
+ >
736
+ {emoji}
737
+ </button>
738
+ {/each}
739
+ </div>
740
+ {:else if field.type === "stepper"}
741
+ {@const v = asNum(value, 0)}
742
+ <div class="biab-field__stepper">
743
+ <button type="button" class="biab-btn biab-btn--ghost" onclick={() => onFieldInput(field.id, v - 1)}>−</button>
744
+ <span class="biab-field__stepper-value">{v}</span>
745
+ <button type="button" class="biab-btn biab-btn--ghost" onclick={() => onFieldInput(field.id, v + 1)}>+</button>
746
+ </div>
747
+ {:else if field.type === "address"}
748
+ {@const v = asObj(value)}
749
+ <div class="biab-field__address">
750
+ <label class="biab-field__sub">
751
+ <span class="biab-field__sublabel">Address Line 1</span>
752
+ <input class="biab-input" autocomplete={acOff ? "off" : "street-address"} value={asStr(v.line1)}
753
+ oninput={(e) => onFieldInput(field.id, { ...v, line1: e.currentTarget.value })} />
754
+ </label>
755
+ <label class="biab-field__sub">
756
+ <span class="biab-field__sublabel">Address Line 2</span>
757
+ <input class="biab-input" autocomplete={acOff ? "off" : "address-line2"} value={asStr(v.line2)}
758
+ oninput={(e) => onFieldInput(field.id, { ...v, line2: e.currentTarget.value })} />
759
+ </label>
760
+ <label class="biab-field__sub">
761
+ <span class="biab-field__sublabel">City</span>
762
+ <input class="biab-input" autocomplete={acOff ? "off" : "address-level2"} value={asStr(v.city)}
763
+ oninput={(e) => onFieldInput(field.id, { ...v, city: e.currentTarget.value })} />
764
+ </label>
765
+ <label class="biab-field__sub">
766
+ <span class="biab-field__sublabel">State</span>
767
+ <input class="biab-input" autocomplete={acOff ? "off" : "address-level1"} value={asStr(v.state)}
768
+ oninput={(e) => onFieldInput(field.id, { ...v, state: e.currentTarget.value })} />
769
+ </label>
770
+ <label class="biab-field__sub">
771
+ <span class="biab-field__sublabel">Zip</span>
772
+ <input class="biab-input" autocomplete={acOff ? "off" : "postal-code"} value={asStr(v.zip)}
773
+ oninput={(e) => onFieldInput(field.id, { ...v, zip: e.currentTarget.value })} />
774
+ </label>
775
+ </div>
776
+ {:else if field.type === "color_picker"}
777
+ {@const v = asStr(value)}
778
+ <div class="biab-field__colors">
779
+ {#each field.options ?? [] as color (color)}
780
+ <button
781
+ type="button"
782
+ class="biab-field__color"
783
+ class:biab-field__color--on={v === color}
784
+ style={`background-color:${color}`}
785
+ title={color}
786
+ aria-label={color}
787
+ onclick={() => onFieldInput(field.id, color)}
788
+ ></button>
789
+ {/each}
790
+ </div>
791
+ {:else if field.type === "file_upload"}
792
+ {@const list = Array.isArray(value) ? (value as Array<{ name?: string; size?: number }>) : []}
793
+ {#if hasClient}
794
+ <div class="biab-field__file">
795
+ <input
796
+ type="file"
797
+ multiple={(field.fileConfig?.maxFiles ?? 1) > 1}
798
+ accept={field.fileConfig?.allowedTypes?.join(",") || undefined}
799
+ disabled={snapshot.uploading}
800
+ onchange={(e) => onFilePick(field, e.currentTarget.files)}
801
+ />
802
+ {#if snapshot.uploading}<span class="biab-field__file-status">Uploading…</span>{/if}
803
+ {#if list.length > 0}
804
+ <ul class="biab-field__file-list">
805
+ {#each list as f, i (i)}
806
+ <li>{f.name ?? "file"}{f.size ? ` · ${Math.round((f.size ?? 0) / 1024)} KB` : ""}</li>
807
+ {/each}
808
+ </ul>
809
+ {/if}
810
+ {#if uploadError}<p class="biab-field__error">{uploadError}</p>{/if}
811
+ </div>
812
+ {:else}
813
+ <div class="biab-field__file biab-field__file--disabled">
814
+ File upload requires an SDK client (uploads are disabled in schema-only mode).
815
+ </div>
816
+ {/if}
817
+ {:else if field.type === "phone"}
818
+ <input
819
+ class="biab-input biab-field__control"
820
+ type="tel"
821
+ placeholder={field.placeholder ||
822
+ (field.phoneConfig?.international ? "+1 (555) 000-0000" : "(555) 000-0000")}
823
+ autocomplete={ac ?? undefined}
824
+ value={asStr(value)}
825
+ oninput={(e) => onFieldInput(field.id, e.currentTarget.value)}
826
+ />
827
+ {:else if field.type === "email"}
828
+ <input
829
+ class="biab-input biab-field__control"
830
+ type="email"
831
+ placeholder={field.placeholder || "you@example.com"}
832
+ autocomplete={ac ?? undefined}
833
+ value={asStr(value)}
834
+ oninput={(e) => onFieldInput(field.id, e.currentTarget.value)}
835
+ />
836
+ {:else if field.type === "number"}
837
+ <input
838
+ class="biab-input biab-field__control"
839
+ type="number"
840
+ placeholder={field.placeholder ?? ""}
841
+ value={typeof value === "number" ? value : asStr(value)}
842
+ oninput={(e) =>
843
+ onFieldInput(field.id, e.currentTarget.value === "" ? "" : Number(e.currentTarget.value))}
844
+ />
845
+ {:else}
846
+ <input
847
+ class="biab-input biab-field__control"
848
+ placeholder={field.placeholder ?? ""}
849
+ autocomplete={ac ?? undefined}
850
+ value={asStr(value)}
851
+ oninput={(e) => onFieldInput(field.id, e.currentTarget.value)}
852
+ />
853
+ {/if}
854
+ {/snippet}
855
+
856
+ {#snippet fieldRow(field: FormCoreField, snapshot: FormSnapshot)}
857
+ {@const errored = Boolean(snapshot.errors[field.id])}
858
+ <div
859
+ class="biab-field biab-field--{field.type}"
860
+ class:biab-field--required={field.required}
861
+ class:biab-field--error={errored}
862
+ data-field-id={field.id}
863
+ data-biab-field={fieldCssKey(field)}
864
+ data-biab-type={field.type}
865
+ >
866
+ {#if !LABELLESS.has(field.type)}
867
+ <span class="biab-label biab-field__label">
868
+ {field.label || ""}{#if field.required}<span class="biab-field__required"> *</span>{/if}
869
+ </span>
870
+ {/if}
871
+ {@render fieldInput(field, snapshot)}
872
+ {#if errored}
873
+ <p class="biab-field__error">{snapshot.errors[field.id]?.message}</p>
874
+ {/if}
875
+ {#if field.helpText}
876
+ <p class="biab-field__help">{field.helpText}</p>
877
+ {/if}
878
+ </div>
879
+ {/snippet}
880
+
881
+ <!-- The recursive tree walk — mirrors FormPreview's RenderTree. -->
882
+ {#snippet renderTree(nodes: FormCoreField[], snapshot: FormSnapshot, flat: boolean)}
883
+ <div class="biab-tree">
884
+ {#each nodes as f (f.id)}
885
+ {#if f.type === "input_group"}
886
+ {@const kids = (f.children ?? []) as FormCoreField[]}
887
+ {@const reveal = revealCountFor(f, snapshot.values)}
888
+ <div class="biab-group" class:biab-group--card={!flat} data-biab-group={fieldCssKey(f)}>
889
+ {#if f.label && f.showGroupLabel}
890
+ <div class="biab-group__label">{f.label}</div>
891
+ {/if}
892
+ {@render renderTree(kids.slice(0, reveal), snapshot, false)}
893
+ </div>
894
+ {:else if f.type === "condition_block"}
895
+ {@const passed = conditionChildren(f, snapshot.values)}
896
+ {#if passed}
897
+ {@render renderTree(passed, snapshot, flat)}
898
+ {/if}
899
+ {:else if f.type === "or_condition"}
900
+ {@const errored = Boolean(snapshot.errors[f.id])}
901
+ {@const left = f.children?.[0]}
902
+ {@const right = f.children?.[1]}
903
+ <div class="biab-or" class:biab-or--error={errored}>
904
+ {#if f.label}
905
+ <div class="biab-or__label">
906
+ {f.label}<span class="biab-or__hint"> Fill at least one side</span>
907
+ </div>
908
+ {/if}
909
+ <div class="biab-or__cols">
910
+ <div class="biab-or__col">
911
+ <div class="biab-or__col-label">{f.orConditionConfig?.leftLabel || "Option A"}</div>
912
+ {#if left}{@render renderTree([left], snapshot, false)}{/if}
913
+ </div>
914
+ <div class="biab-or__sep">OR</div>
915
+ <div class="biab-or__col">
916
+ <div class="biab-or__col-label">{f.orConditionConfig?.rightLabel || "Option B"}</div>
917
+ {#if right}{@render renderTree([right], snapshot, false)}{/if}
918
+ </div>
919
+ </div>
920
+ {#if errored}
921
+ <p class="biab-field__error">Fill in at least one side to continue.</p>
922
+ {/if}
923
+ </div>
924
+ {:else if f.type === "flex_row"}
925
+ {@const rowKids = (f.children ?? []) as FormCoreField[]}
926
+ <div class="biab-flex-row">
927
+ {#each rowKids as child (child.id)}
928
+ <div class="biab-flex-row__cell">
929
+ {@render fieldRow(child, snapshot)}
930
+ </div>
931
+ {/each}
932
+ </div>
933
+ {:else}
934
+ {@render fieldRow(f, snapshot)}
935
+ {/if}
936
+ {/each}
937
+ </div>
938
+ {/snippet}
939
+
940
+ <section class={rootClass}>
941
+ {#if $snap.orgIcon}
942
+ <img class="biab-form__org-icon" src={$snap.orgIcon} alt="" />
943
+ {/if}
944
+
945
+ {#if $snap.status === "loading"}
946
+ <div class="biab-loading">Loading form…</div>
947
+ {:else if $snap.status === "error"}
948
+ <div class="biab-empty biab-form__error">{$snap.loadError ?? "Couldn't load the form."}</div>
949
+ {:else if submitted}
950
+ <div class="biab-card biab-form__success" role="status">
951
+ <span class="biab-badge">Received</span>
952
+ <p>{successMessage}</p>
953
+ </div>
954
+ {:else}
955
+ {#if $snap.title || $snap.description}
956
+ <div class="biab-form__lead">
957
+ {#if $snap.title}<h2 class="biab-section__title">{$snap.title}</h2>{/if}
958
+ {#if $snap.description}<p class="biab-section__sub">{$snap.description}</p>{/if}
959
+ </div>
960
+ {/if}
961
+
962
+ <!-- Multi-step progress: "steps" indicator. -->
963
+ {#if $snap.settings.isConsecutive && $snap.settings.showProgress && $snap.settings.progressStyle !== "bar" && $snap.stepCount > 1}
964
+ <div class="biab-progress biab-progress--steps">
965
+ <div class="biab-progress__heading">Form Progress</div>
966
+ <ol class="biab-progress__steps">
967
+ {#each $snap.steps as s, i (i)}
968
+ {@const head = s[0]}
969
+ {@const stepLabel = head?.stepTitle || head?.label || `Step ${i + 1}`}
970
+ <li
971
+ class="biab-progress__step"
972
+ class:biab-progress__step--current={i === $snap.step}
973
+ class:biab-progress__step--done={i < $snap.step}
974
+ >
975
+ <button type="button" class="biab-progress__dot" onclick={() => controller.goToStep(i)}>
976
+ {i < $snap.step ? "✓" : i + 1}
977
+ </button>
978
+ <span class="biab-progress__label">{stepLabel}</span>
979
+ </li>
980
+ {/each}
981
+ </ol>
982
+ </div>
983
+ {/if}
984
+
985
+ <!-- Multi-step progress: thin "bar". -->
986
+ {#if $snap.settings.isConsecutive && $snap.settings.showProgress && $snap.settings.progressStyle === "bar" && $snap.stepCount > 1}
987
+ <div class="biab-progress biab-progress--bar">
988
+ <span class="biab-progress__count">{$snap.step + 1} / {$snap.stepCount}</span>
989
+ <div class="biab-progress__track">
990
+ <div class="biab-progress__fill" style={`width:${$snap.progressPercent}%`}></div>
991
+ </div>
992
+ </div>
993
+ {/if}
994
+
995
+ {#if $snap.errorList.length > 0}
996
+ <div class="biab-form__errors" role="alert">
997
+ <div class="biab-form__errors-title">
998
+ Please complete the highlighted required field{$snap.errorList.length === 1 ? "" : "s"}.
999
+ </div>
1000
+ <div class="biab-form__errors-list">
1001
+ {$snap.errorList.map((e) => e.label).join(", ")}
1002
+ </div>
1003
+ </div>
1004
+ {/if}
1005
+
1006
+ <!-- The active step's subtree (single-page = all fields). -->
1007
+ {#if $snap.settings.animation === "scroll"}
1008
+ <div class="biab-tree">
1009
+ {#each $snap.visibleFields as vf (vf.id)}
1010
+ {@render renderTree([vf], $snap, $snap.settings.isConsecutive)}
1011
+ {/each}
1012
+ </div>
1013
+ {:else}
1014
+ {#key $snap.step}
1015
+ <div class={entranceClass($snap.settings.animation, $snap.forward)}>
1016
+ {@render renderTree($snap.visibleFields, $snap, $snap.settings.isConsecutive)}
1017
+ </div>
1018
+ {/key}
1019
+ {/if}
1020
+
1021
+ {#if submitError}
1022
+ <div class="biab-form__submit-error" role="alert">{submitError}</div>
1023
+ {/if}
1024
+
1025
+ <!-- Nav: multi-step (Back / Next / Submit) or single-page (Submit). -->
1026
+ {#if $snap.settings.isConsecutive && $snap.stepCount > 1}
1027
+ <div class="biab-nav">
1028
+ <button
1029
+ type="button"
1030
+ class="biab-btn biab-btn--ghost biab-btn--back"
1031
+ disabled={$snap.isFirstStep}
1032
+ onclick={() => controller.back()}
1033
+ >
1034
+ Back
1035
+ </button>
1036
+ {#if !$snap.isLastStep}
1037
+ <button
1038
+ type="button"
1039
+ class="biab-btn biab-btn--next"
1040
+ disabled={!$snap.canAdvance}
1041
+ onclick={() => controller.next()}
1042
+ >
1043
+ Next
1044
+ </button>
1045
+ {:else}
1046
+ <button
1047
+ type="button"
1048
+ class="biab-btn biab-btn--submit"
1049
+ disabled={submitting}
1050
+ onclick={() => void handleSubmit()}
1051
+ >
1052
+ {submitting ? "Sending…" : submitLabel}
1053
+ </button>
1054
+ {/if}
1055
+ </div>
1056
+ {:else}
1057
+ <div class="biab-nav">
1058
+ <button
1059
+ type="button"
1060
+ class="biab-btn biab-btn--submit"
1061
+ disabled={submitting}
1062
+ onclick={() => void handleSubmit()}
1063
+ >
1064
+ {submitting ? "Sending…" : submitLabel}
1065
+ </button>
1066
+ </div>
1067
+ {/if}
1068
+ {/if}
1069
+ </section>