@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,1538 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * <BiabForm> — Vue 3 drop-in binding for the headless `@businessdash/sdk/forms`
4
+ * core. Ship mode: SOURCE (.vue). The consumer's Vite/Nuxt toolchain compiles
5
+ * this single-file component; biab-dev does NOT pre-build it.
6
+ *
7
+ * It is a faithful port of the dashboard renderer (`FormPreview.tsx`): the same
8
+ * per-field rendering by `type`, the same `condition_block` / `or_condition`
9
+ * evaluation, the same multi-step ("consecutive") nav + progress bar/steps, the
10
+ * same concurrent-reveal gating, default seeding, and form-settings application
11
+ * (animation, showProgress/progressStyle, showOrgIcon + org icon). Everything
12
+ * renders into LIGHT DOM (no shadow root) using the `biab-*` CSS class +
13
+ * `data-biab-*` attribute conventions, so a consumer's own CSS cascades 1:1
14
+ * with the dashboard preview.
15
+ *
16
+ * All runtime state + behaviour lives in `createFormController` (values,
17
+ * errors, steps, visibility, submit lifecycle, file uploads). This component is
18
+ * a thin reactive shell: it subscribes to `controller.snapshot()` via a
19
+ * `shallowRef` and tears the subscription down on `onScopeDispose`.
20
+ *
21
+ * Two submit paths, picked automatically:
22
+ * • `client` given → the controller submits through the SDK
23
+ * (`client.forms.submit`) — auth/caching/HMAC/CRM fan-out stay invisible.
24
+ * • `onSubmit` given → the controller validates locally, then hands the
25
+ * re-keyed wire payload to your callback (e.g. POST it to your own
26
+ * server proxy, the pattern Nuxt/SSR uses to keep the bearer key off the
27
+ * client). `onSuccess` / `onError` still fire.
28
+ *
29
+ * The recursive field-tree renderer is declared as a local render-function
30
+ * component (`BiabFieldTree`) right here in `<script setup>` so the whole
31
+ * binding stays ONE self-contained `.vue` file (the SOURCE ship-mode contract)
32
+ * while still supporting self-reference for nested groups / conditions.
33
+ */
34
+
35
+ import type { BiabClient } from "@businessdash/sdk";
36
+ import {
37
+ concurrentRevealCount,
38
+ createFormController,
39
+ evalCondition,
40
+ type FieldError,
41
+ fetchIconNames,
42
+ filterIconNames,
43
+ type FormController,
44
+ type FormCoreField,
45
+ type FormData,
46
+ type FormSchema,
47
+ type FormSnapshot,
48
+ type FormSubmitOptions,
49
+ type FormSubmitResult,
50
+ iconUrl,
51
+ shouldSearchOptions,
52
+ } from "@businessdash/sdk/forms";
53
+
54
+ import {
55
+ type AutocompleteConfig,
56
+ resolveFieldAutocomplete,
57
+ } from "../forms-core/autocomplete.js";
58
+ import {
59
+ computed,
60
+ defineComponent,
61
+ h,
62
+ inject,
63
+ onMounted,
64
+ onScopeDispose,
65
+ type PropType,
66
+ ref,
67
+ shallowRef,
68
+ watch,
69
+ } from "vue";
70
+
71
+ import { BIAB_CLIENT_KEY } from "./index";
72
+
73
+ // ─── Props / emits ────────────────────────────────────────────────────────────
74
+
75
+ type SuccessResult = Extract<FormSubmitResult, { ok: true }>;
76
+ type FailureResult = Extract<FormSubmitResult, { ok: false }>;
77
+
78
+ const props = withDefaults(
79
+ defineProps<{
80
+ /** Form slug — required when loading/submitting via an SDK `client`. With a
81
+ * pre-fetched `schema` the slug is read from `schema.slug` if omitted. */
82
+ slug?: string;
83
+ /** Pre-fetched schema (SSR / RSC). Adopted synchronously — no network. When
84
+ * given without a `client`, pair it with `onSubmit` to post the payload
85
+ * through your own server proxy. */
86
+ schema?: FormSchema;
87
+ /** SDK client. Optional — falls back to a `provide(BIAB_CLIENT_KEY, …)` /
88
+ * `<BiabProvider>` ancestor. Browser-only: never pass a server-bound key in
89
+ * a client-rendered tree (use `schema` + `onSubmit` instead). */
90
+ client?: BiabClient;
91
+ /** Submit-options merged into every submit (the call's own options win). */
92
+ submitOptions?: FormSubmitOptions;
93
+ /** Validate on the server, store nothing (no side effects). */
94
+ dryRun?: boolean;
95
+ /** Recipient passthrough forwarded to the underlying submit. */
96
+ submitterEmail?: string;
97
+ submitterName?: string;
98
+ /** Submit button label (live mode). */
99
+ submitLabel?: string;
100
+ /**
101
+ * Custom submit. When provided, the controller validates locally and then
102
+ * this receives the re-keyed wire payload (keyed by each field's output
103
+ * `key`). Return a `FormSubmitResult` to drive success/error UI precisely,
104
+ * or resolve/throw — a thrown error surfaces as a `network_error` failure.
105
+ * This is how SSR consumers submit without shipping the bearer key.
106
+ */
107
+ onSubmit?: (
108
+ payload: FormData,
109
+ opts: FormSubmitOptions,
110
+ ) => void | FormSubmitResult | Promise<void | FormSubmitResult>;
111
+ /** Class(es) merged onto the root `.biab-form` element. */
112
+ class?: unknown;
113
+ /** Alias for `class` (cross-framework parity). */
114
+ className?: string;
115
+ /** Hide the built-in success card and let the parent render via `@success`. */
116
+ hideSuccess?: boolean;
117
+ /**
118
+ * Global HTML autofill switch. Defaults to inferring a token per field
119
+ * (so an email field gets `autocomplete="email"`, etc.). Pass `false`
120
+ * (template: `:auto-complete="false"`) to emit `autocomplete="off"` on
121
+ * every value-bearing input.
122
+ */
123
+ autoComplete?: boolean;
124
+ /**
125
+ * Per-field autofill overrides keyed by field id: a literal token, `true`
126
+ * (infer), or `false` (off). Wins over `autoComplete` and any schema-level
127
+ * token. Template: `:field-auto-complete="{ … }"`.
128
+ */
129
+ fieldAutoComplete?: Record<string, string | boolean>;
130
+ }>(),
131
+ {
132
+ submitLabel: "Submit",
133
+ },
134
+ );
135
+
136
+ const emit = defineEmits<{
137
+ (e: "success", result: SuccessResult): void;
138
+ (e: "error", result: FailureResult): void;
139
+ (e: "submit", result: FormSubmitResult): void;
140
+ }>();
141
+
142
+ // ─── Resolve the client (prop → injected provider) ──────────────────────────────
143
+
144
+ const injectedClient = inject(BIAB_CLIENT_KEY, null);
145
+ const resolvedClient = computed<BiabClient | undefined>(
146
+ () => props.client ?? injectedClient ?? undefined,
147
+ );
148
+
149
+ // ─── Submit-option assembly ─────────────────────────────────────────────────────
150
+
151
+ function mergedSubmitOptions(): FormSubmitOptions {
152
+ const opts: FormSubmitOptions = { ...props.submitOptions };
153
+ if (props.dryRun) opts.dryRun = true;
154
+ if (props.submitterEmail) opts.submitterEmail = props.submitterEmail;
155
+ if (props.submitterName) opts.submitterName = props.submitterName;
156
+ return opts;
157
+ }
158
+
159
+ // ─── Controller wiring ─────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Build a controller for the current schema/client. We pass the SDK client only
163
+ * when there's no `onSubmit` override — with an override we own the network call
164
+ * and the controller stays client-free (so it never tries to fetch the schema or
165
+ * submit itself). When a `schema` is supplied we always adopt it synchronously.
166
+ */
167
+ function buildController(): FormController {
168
+ const useOwnSubmit = typeof props.onSubmit === "function";
169
+ return createFormController({
170
+ client: useOwnSubmit ? undefined : resolvedClient.value,
171
+ slug: props.slug ?? props.schema?.slug,
172
+ schema: props.schema,
173
+ submitOptions: mergedSubmitOptions(),
174
+ onSuccess: (result) => {
175
+ emit("success", result);
176
+ emit("submit", result);
177
+ },
178
+ onError: (result) => {
179
+ emit("error", result);
180
+ emit("submit", result);
181
+ },
182
+ });
183
+ }
184
+
185
+ // `onSubmit` results aren't tracked by the controller (it never ran), so keep a
186
+ // local mirror for the success/error UI in that path. Declared before the
187
+ // controller wiring so the `watch`/expose closures reference an initialized ref.
188
+ const ownResult = shallowRef<FormSubmitResult | null>(null);
189
+
190
+ let controller: FormController = buildController();
191
+ const snap = shallowRef<FormSnapshot>(controller.snapshot());
192
+ let unsubscribe: (() => void) | null = null;
193
+
194
+ function attach(c: FormController): void {
195
+ unsubscribe = c.subscribe((s) => {
196
+ // A fresh snapshot object per notify + shallowRef => Vue re-renders.
197
+ snap.value = s;
198
+ });
199
+ snap.value = c.snapshot();
200
+ void c.load();
201
+ }
202
+
203
+ attach(controller);
204
+
205
+ // Re-create the controller when the schema, client, slug, or submit-mode changes.
206
+ watch(
207
+ () => [props.schema, resolvedClient.value, props.slug, typeof props.onSubmit],
208
+ () => {
209
+ unsubscribe?.();
210
+ controller.destroy();
211
+ ownResult.value = null;
212
+ controller = buildController();
213
+ attach(controller);
214
+ },
215
+ );
216
+
217
+ onMounted(() => {
218
+ // SSR adopts the schema in setup; this covers a schema that arrives later.
219
+ if (snap.value.status === "idle") void controller.load();
220
+ });
221
+
222
+ onScopeDispose(() => {
223
+ unsubscribe?.();
224
+ controller.destroy();
225
+ });
226
+
227
+ // ─── Submit (client path OR onSubmit override) ─────────────────────────────────
228
+
229
+ async function handleSubmit(callOpts?: FormSubmitOptions): Promise<void> {
230
+ const merged: FormSubmitOptions = { ...mergedSubmitOptions(), ...callOpts };
231
+
232
+ // Override path: validate locally, then hand the wire payload to the parent.
233
+ if (typeof props.onSubmit === "function") {
234
+ const missing = controller.validate();
235
+ if (missing.length > 0) return; // errors already published to the snapshot
236
+ const wire = toWirePayload(snap.value.fields, snap.value.values);
237
+ let result: FormSubmitResult;
238
+ try {
239
+ const r = await props.onSubmit(wire, merged);
240
+ result =
241
+ r && typeof r === "object" && "ok" in r
242
+ ? (r as FormSubmitResult)
243
+ : {
244
+ ok: true,
245
+ status: 200,
246
+ ...(merged.dryRun ? { dryRun: true } : {}),
247
+ };
248
+ } catch (err) {
249
+ result = {
250
+ ok: false,
251
+ status: 0,
252
+ reason: "network_error",
253
+ message:
254
+ err instanceof Error ? err.message : "The submission failed.",
255
+ };
256
+ }
257
+ ownResult.value = result;
258
+ if (result.ok) {
259
+ emit("success", result);
260
+ emit("submit", result);
261
+ } else {
262
+ emit("error", result);
263
+ emit("submit", result);
264
+ }
265
+ return;
266
+ }
267
+
268
+ // Client path: the controller submits through the SDK.
269
+ await controller.submit(merged);
270
+ }
271
+
272
+ const effectiveResult = computed<FormSubmitResult | null>(
273
+ () => ownResult.value ?? snap.value.result,
274
+ );
275
+ const effectiveSubmitted = computed(() => effectiveResult.value?.ok === true);
276
+
277
+ // ─── Imperative API exposed to template refs ────────────────────────────────────
278
+
279
+ defineExpose({
280
+ submit: (opts?: FormSubmitOptions) => handleSubmit(opts),
281
+ reset: () => {
282
+ ownResult.value = null;
283
+ controller.reset();
284
+ },
285
+ validate: () => controller.validate(),
286
+ next: () => controller.next(),
287
+ back: () => controller.back(),
288
+ snapshot: () => controller.snapshot(),
289
+ });
290
+
291
+ // ─── id → output-key re-keying (mirrors core toSubmissionPayload) ───────────────
292
+ function toWirePayload(fields: FormCoreField[], data: FormData): FormData {
293
+ const idToKey = new Map<string, string>();
294
+ const walk = (nodes: FormCoreField[]) => {
295
+ for (const f of nodes) {
296
+ if (f.id && f.key) idToKey.set(f.id, f.key);
297
+ if (f.children) walk(f.children);
298
+ }
299
+ };
300
+ walk(fields);
301
+ const out: FormData = {};
302
+ for (const [id, value] of Object.entries(data)) {
303
+ out[idToKey.get(id) ?? id] = value;
304
+ }
305
+ return out;
306
+ }
307
+
308
+ // ─── File-upload bridge (controller owns the SDK call + value append) ───────────
309
+ async function onFilePick(fieldId: string, ev: Event): Promise<void> {
310
+ const input = ev.target as HTMLInputElement;
311
+ const picked = input.files;
312
+ if (!picked || picked.length === 0) return;
313
+ const files = Array.isArray(snap.value.values[fieldId])
314
+ ? (snap.value.values[fieldId] as unknown[])
315
+ : [];
316
+ // Determine remaining slots from the field's maxFiles (best-effort UX cap).
317
+ const field = flatFind(snap.value.fields, fieldId);
318
+ const maxFiles = field?.fileConfig?.maxFiles ?? 1;
319
+ const slots = Math.max(maxFiles - files.length, 0);
320
+ const list = Array.from(picked).slice(0, slots);
321
+ try {
322
+ for (const file of list) {
323
+ await controller.uploadFile(fieldId, file, { filename: file.name });
324
+ }
325
+ } finally {
326
+ input.value = "";
327
+ }
328
+ }
329
+
330
+ function removeFile(fieldId: string, idx: number): void {
331
+ const arr = Array.isArray(snap.value.values[fieldId])
332
+ ? (snap.value.values[fieldId] as unknown[])
333
+ : [];
334
+ controller.setValue(
335
+ fieldId,
336
+ arr.filter((_, i) => i !== idx),
337
+ );
338
+ }
339
+
340
+ function flatFind(
341
+ nodes: FormCoreField[],
342
+ id: string,
343
+ ): FormCoreField | undefined {
344
+ for (const f of nodes) {
345
+ if (f.id === id) return f;
346
+ if (f.children) {
347
+ const hit = flatFind(f.children, id);
348
+ if (hit) return hit;
349
+ }
350
+ }
351
+ return undefined;
352
+ }
353
+
354
+ // ─── Render-state derivations (mirror FormPreview) ──────────────────────────────
355
+
356
+ const showStepsProgress = computed(
357
+ () =>
358
+ snap.value.settings.isConsecutive &&
359
+ snap.value.settings.showProgress &&
360
+ snap.value.settings.progressStyle !== "bar" &&
361
+ snap.value.stepCount > 1,
362
+ );
363
+ const showBarProgress = computed(
364
+ () =>
365
+ snap.value.settings.isConsecutive &&
366
+ snap.value.settings.showProgress &&
367
+ snap.value.settings.progressStyle === "bar" &&
368
+ snap.value.stepCount > 1,
369
+ );
370
+ const isConsecutive = computed(() => snap.value.settings.isConsecutive);
371
+ const isMultiStep = computed(
372
+ () => isConsecutive.value && snap.value.stepCount > 1,
373
+ );
374
+
375
+ /** Entrance animation class for the active step (directional slide). */
376
+ const entranceClass = computed(() => {
377
+ const a = snap.value.settings.animation;
378
+ const fwd = snap.value.forward;
379
+ switch (a) {
380
+ case "fade":
381
+ return "biab-anim biab-anim--fade";
382
+ case "scale":
383
+ return "biab-anim biab-anim--scale";
384
+ case "slide":
385
+ return fwd
386
+ ? "biab-anim biab-anim--slide-fwd"
387
+ : "biab-anim biab-anim--slide-back";
388
+ default:
389
+ return "";
390
+ }
391
+ });
392
+
393
+ function stepTitle(step: FormCoreField[], i: number): string {
394
+ const f = step[0];
395
+ return (
396
+ (typeof f?.stepTitle === "string" && f.stepTitle) ||
397
+ (typeof f?.label === "string" && f.label) ||
398
+ `Step ${i + 1}`
399
+ );
400
+ }
401
+
402
+ const rootClass = computed(() => ["biab-form", props.className, props.class]);
403
+ const submitDisabled = computed(
404
+ () => snap.value.submitting || snap.value.uploading,
405
+ );
406
+
407
+ // ─── Autofill config threaded to the field renderer ─────────────────────────────
408
+ // Mirrors the React adapter: a global `enabled` switch plus per-field `overrides`.
409
+ // `resolveFieldAutocomplete(field, config)` turns this into the final token (or
410
+ // `undefined` to omit the attribute) per input. See forms-core/autocomplete.ts.
411
+ const autocompleteConfig = computed<AutocompleteConfig>(() => ({
412
+ ...(props.autoComplete !== undefined ? { enabled: props.autoComplete } : {}),
413
+ ...(props.fieldAutoComplete ? { overrides: props.fieldAutoComplete } : {}),
414
+ }));
415
+
416
+ // ─── Context bag handed to the recursive renderer ───────────────────────────────
417
+ type FieldCtx = {
418
+ values: FormData;
419
+ errors: Record<string, FieldError>;
420
+ setValue: (id: string, v: unknown) => void;
421
+ uploadFile: (id: string, ev: Event) => void;
422
+ removeFile: (id: string, idx: number) => void;
423
+ autocomplete: AutocompleteConfig;
424
+ };
425
+ const fieldCtx = computed<FieldCtx>(() => ({
426
+ values: snap.value.values,
427
+ errors: snap.value.errors,
428
+ setValue: (id, v) => controller.setValue(id, v),
429
+ uploadFile: (id, ev) => void onFilePick(id, ev),
430
+ removeFile,
431
+ autocomplete: autocompleteConfig.value,
432
+ }));
433
+
434
+ // ─── icon_select / image_input shared state ─────────────────────────────────
435
+ // Declared here (setup scope) so the recursive field-tree component below closes
436
+ // over them, the same way it closes over `controller`.
437
+
438
+ /** Current search text in an icon picker. */
439
+ const iconQuery = ref("");
440
+ /** Lazily-fetched icon-name index — ~1,600 entries that change only when Lucide
441
+ * does, so it's fetched once per mount and never bundled. */
442
+ const iconIndex = ref<string[]>([]);
443
+ let iconIndexRequested = false;
444
+
445
+ async function loadIconIndex(): Promise<void> {
446
+ if (iconIndexRequested) return;
447
+ iconIndexRequested = true;
448
+ // Resolves to [] on failure — an empty grid is recoverable, a thrown error
449
+ // during render is not.
450
+ iconIndex.value = await fetchIconNames();
451
+ }
452
+
453
+ /** Which side of an `image_input` the visitor is on, per field id. */
454
+ const imageModes = ref<Record<string, "upload" | "url">>({});
455
+
456
+ // ─── BiabFieldTree — recursive render-function component (FormPreview port) ──────
457
+
458
+ const STATIC_NO_LABEL = new Set(["title", "subtitle", "separator", "richtext"]);
459
+
460
+ function cssKey(f: FormCoreField): string {
461
+ const explicit = typeof f.outputKey === "string" ? f.outputKey.trim() : "";
462
+ return explicit || f.key || f.id;
463
+ }
464
+
465
+ const BiabFieldTree = defineComponent({
466
+ name: "BiabFieldTree",
467
+ props: {
468
+ nodes: { type: Array as PropType<FormCoreField[]>, required: true },
469
+ flat: { type: Boolean, default: false },
470
+ ctx: { type: Object as PropType<FieldCtx>, required: true },
471
+ },
472
+ setup(p) {
473
+ const renderInput = (f: FormCoreField) => {
474
+ const id = f.id;
475
+ const value = p.ctx.values[id];
476
+ const set = (v: unknown) => p.ctx.setValue(id, v);
477
+ // Resolved HTML autofill token for this field (or `undefined` to omit
478
+ // the attribute). Honors the global `autoComplete` switch + per-field
479
+ // overrides; mirrors the React adapter. Vue drops `undefined` attrs.
480
+ const autocomplete = resolveFieldAutocomplete(f, p.ctx.autocomplete);
481
+
482
+ switch (f.type) {
483
+ case "title":
484
+ return h("h2", { class: "biab-field__title" }, f.label || "");
485
+ case "subtitle":
486
+ return h("h3", { class: "biab-field__subtitle" }, f.label || "");
487
+ case "separator":
488
+ return h("hr", { class: "biab-field__separator" });
489
+ case "richtext":
490
+ return f.content
491
+ ? h("div", {
492
+ class: "biab-field__richtext",
493
+ innerHTML: String(f.content),
494
+ })
495
+ : null;
496
+ case "image": {
497
+ const cfg =
498
+ (f.imageConfig as
499
+ | {
500
+ src?: string;
501
+ alt?: string;
502
+ width?: number;
503
+ height?: number;
504
+ }
505
+ | undefined) ?? {};
506
+ return cfg.src
507
+ ? h("img", {
508
+ class: "biab-field__image",
509
+ src: cfg.src,
510
+ alt: cfg.alt || "",
511
+ style: {
512
+ width: cfg.width ? `${cfg.width}px` : "auto",
513
+ height: cfg.height ? `${cfg.height}px` : "auto",
514
+ maxWidth: "100%",
515
+ },
516
+ })
517
+ : null;
518
+ }
519
+ case "textarea": {
520
+ const cfg =
521
+ (f.textareaConfig as
522
+ | { rows?: number; resizable?: boolean }
523
+ | undefined) ?? {};
524
+ return h("textarea", {
525
+ autocomplete,
526
+ class: "biab-textarea biab-field__control",
527
+ value: typeof value === "string" ? value : "",
528
+ placeholder: f.placeholder || "",
529
+ rows: cfg.rows ?? 4,
530
+ style: { resize: cfg.resizable ? "vertical" : "none" },
531
+ onInput: (e: Event) =>
532
+ set((e.target as HTMLTextAreaElement).value),
533
+ });
534
+ }
535
+ case "select": {
536
+ const opts = f.options ?? [];
537
+ // ComboBox card, or a list long enough that scrolling is worse.
538
+ // `input list` + `datalist` = native typeahead, no JS, no styling
539
+ // opinion — which is the SDK's contract.
540
+ if (
541
+ shouldSearchOptions(
542
+ opts.length,
543
+ (f as { display?: string }).display,
544
+ )
545
+ ) {
546
+ const listId = `biab-list-${f.id}`;
547
+ // A wrapper rather than a fragment: `renderInput` is typed to one
548
+ // VNode, and `<datalist>` renders nothing visually anyway.
549
+ return h("div", { class: "biab-combobox-wrap" }, [
550
+ h("input", {
551
+ class: "biab-input biab-combobox biab-field__control",
552
+ type: "text",
553
+ list: listId,
554
+ placeholder: f.placeholder || "Search…",
555
+ value: typeof value === "string" ? value : "",
556
+ onInput: (e: Event) =>
557
+ set((e.target as HTMLInputElement).value),
558
+ }),
559
+ h(
560
+ "datalist",
561
+ { id: listId },
562
+ opts.map((opt) => h("option", { key: opt, value: opt })),
563
+ ),
564
+ ]);
565
+ }
566
+ return h(
567
+ "select",
568
+ {
569
+ class: "biab-input biab-field__control",
570
+ value: typeof value === "string" ? value : "",
571
+ onChange: (e: Event) =>
572
+ set((e.target as HTMLSelectElement).value),
573
+ },
574
+ [
575
+ h("option", { value: "" }, f.placeholder || "Select..."),
576
+ ...opts.map((opt) => h("option", { key: opt, value: opt }, opt)),
577
+ ],
578
+ );
579
+ }
580
+ case "icon_select": {
581
+ // The value is an icon NAME; art resolves against the platform's
582
+ // icon route so no icon library ships in the SDK bundle.
583
+ const curated = (
584
+ f as { iconConfig?: { allowedIcons?: string[] } }
585
+ ).iconConfig?.allowedIcons;
586
+ const names =
587
+ curated && curated.length > 0 ? curated : iconIndex.value;
588
+ if (
589
+ (!curated || curated.length === 0) &&
590
+ iconIndex.value.length === 0
591
+ ) {
592
+ void loadIconIndex();
593
+ }
594
+ const selected = typeof value === "string" ? value : "";
595
+ return h("div", { class: "biab-iconselect" }, [
596
+ h("input", {
597
+ class: "biab-input biab-iconselect__search",
598
+ type: "text",
599
+ placeholder: f.placeholder || "Search icons…",
600
+ value: iconQuery.value,
601
+ onInput: (e: Event) => {
602
+ iconQuery.value = (e.target as HTMLInputElement).value;
603
+ },
604
+ }),
605
+ selected
606
+ ? h("span", { class: "biab-iconselect__current" }, [
607
+ h("img", {
608
+ src: iconUrl(selected),
609
+ alt: selected,
610
+ width: 16,
611
+ height: 16,
612
+ }),
613
+ h(
614
+ "button",
615
+ {
616
+ type: "button",
617
+ class: "biab-iconselect__clear",
618
+ onClick: () => set(""),
619
+ },
620
+ "×",
621
+ ),
622
+ ])
623
+ : null,
624
+ h(
625
+ "div",
626
+ { class: "biab-iconselect__grid" },
627
+ filterIconNames(names, iconQuery.value).map((name) =>
628
+ h(
629
+ "button",
630
+ {
631
+ key: name,
632
+ type: "button",
633
+ class: `biab-iconselect__item${
634
+ selected === name ? " is-selected" : ""
635
+ }`,
636
+ title: name,
637
+ "aria-label": name,
638
+ "aria-pressed": selected === name,
639
+ onClick: () => set(name),
640
+ },
641
+ [
642
+ h("img", {
643
+ src: iconUrl(name),
644
+ alt: "",
645
+ width: 16,
646
+ height: 16,
647
+ }),
648
+ ],
649
+ ),
650
+ ),
651
+ ),
652
+ ]);
653
+ }
654
+ case "image_input": {
655
+ // One image, either source; the value is a plain URL string.
656
+ const cfg =
657
+ (
658
+ f as {
659
+ imageInputConfig?: {
660
+ allowedTypes?: string[];
661
+ maxSize?: number;
662
+ showPreview?: boolean;
663
+ sources?: "both" | "upload" | "url";
664
+ };
665
+ }
666
+ ).imageInputConfig ?? {};
667
+ const sources = cfg.sources ?? "both";
668
+ const url = typeof value === "string" ? value : "";
669
+ const mode = imageModes.value[f.id] ?? "upload";
670
+ return h("div", { class: "biab-imageinput" }, [
671
+ sources === "both"
672
+ ? h(
673
+ "div",
674
+ { class: "biab-imageinput__modes" },
675
+ (["upload", "url"] as const).map((m) =>
676
+ h(
677
+ "button",
678
+ {
679
+ key: m,
680
+ type: "button",
681
+ class: `biab-imageinput__mode${
682
+ mode === m ? " is-active" : ""
683
+ }`,
684
+ "aria-pressed": mode === m,
685
+ onClick: () => {
686
+ imageModes.value = {
687
+ ...imageModes.value,
688
+ [f.id]: m,
689
+ };
690
+ },
691
+ },
692
+ m === "upload" ? "Upload" : "Paste URL",
693
+ ),
694
+ ),
695
+ )
696
+ : null,
697
+ mode === "upload" && sources !== "url"
698
+ ? h("input", {
699
+ class: "biab-input biab-imageinput__file",
700
+ type: "file",
701
+ accept: (cfg.allowedTypes ?? ["image/*"]).join(","),
702
+ onChange: (e: Event) => {
703
+ const file = (e.target as HTMLInputElement).files?.[0];
704
+ if (!file) return;
705
+ if (file.size > (cfg.maxSize ?? 5) * 1024 * 1024) return;
706
+ void controller
707
+ .uploadFile(f.id, file, { filename: file.name })
708
+ .then((up) => {
709
+ const u = (up as { url?: string } | undefined)?.url;
710
+ if (u) set(u);
711
+ })
712
+ .catch(() => undefined);
713
+ },
714
+ })
715
+ : h("input", {
716
+ class: "biab-input biab-imageinput__url",
717
+ type: "url",
718
+ placeholder:
719
+ f.placeholder || "https://example.com/image.png",
720
+ value: url,
721
+ onInput: (e: Event) =>
722
+ set((e.target as HTMLInputElement).value),
723
+ }),
724
+ cfg.showPreview !== false && url
725
+ ? h("img", {
726
+ class: "biab-imageinput__preview",
727
+ src: url,
728
+ alt: "",
729
+ })
730
+ : null,
731
+ ]);
732
+ }
733
+ case "radio":
734
+ return h(
735
+ "div",
736
+ { class: "biab-field__radio-group" },
737
+ (f.options ?? []).map((opt) =>
738
+ h("label", { key: opt, class: "biab-field__radio" }, [
739
+ h("input", {
740
+ type: "radio",
741
+ name: id,
742
+ value: opt,
743
+ checked: value === opt,
744
+ onChange: () => set(opt),
745
+ }),
746
+ h("span", opt),
747
+ ]),
748
+ ),
749
+ );
750
+ case "checkbox": {
751
+ const arr = Array.isArray(value) ? (value as string[]) : [];
752
+ return h(
753
+ "div",
754
+ { class: "biab-field__checkbox-group" },
755
+ (f.options ?? []).map((opt) =>
756
+ h("label", { key: opt, class: "biab-field__checkbox" }, [
757
+ h("input", {
758
+ type: "checkbox",
759
+ checked: arr.includes(opt),
760
+ onChange: (e: Event) => {
761
+ const c = (e.target as HTMLInputElement).checked;
762
+ set(
763
+ c ? [...arr, opt] : arr.filter((x) => x !== opt),
764
+ );
765
+ },
766
+ }),
767
+ h("span", opt),
768
+ ]),
769
+ ),
770
+ );
771
+ }
772
+ case "boolean":
773
+ return h("label", { class: "biab-field__switch" }, [
774
+ h("input", {
775
+ type: "checkbox",
776
+ checked: Boolean(value),
777
+ onChange: (e: Event) =>
778
+ set((e.target as HTMLInputElement).checked),
779
+ }),
780
+ h("span", f.placeholder || "Yes / No"),
781
+ ]);
782
+ case "date":
783
+ return h("input", {
784
+ type: "date",
785
+ class: "biab-input biab-field__control",
786
+ value: typeof value === "string" ? value : "",
787
+ onInput: (e: Event) =>
788
+ set((e.target as HTMLInputElement).value),
789
+ });
790
+ case "time_picker": {
791
+ const mode = f.timeConfig?.mode ?? "simple";
792
+ if (mode === "simple") {
793
+ const step = Math.max(f.timeConfig?.stepMinutes ?? 30, 1);
794
+ const fmt = f.timeConfig?.format ?? "12h";
795
+ const opts: { v: string; l: string }[] = [];
796
+ for (let hh = 0; hh < 24; hh++) {
797
+ for (let mm = 0; mm < 60; mm += step) {
798
+ const v = `${String(hh).padStart(2, "0")}:${String(
799
+ mm,
800
+ ).padStart(2, "0")}`;
801
+ let l = v;
802
+ if (fmt !== "24h") {
803
+ const ap = hh < 12 ? "AM" : "PM";
804
+ const h12 = hh % 12 === 0 ? 12 : hh % 12;
805
+ l = `${h12}:${String(mm).padStart(2, "0")} ${ap}`;
806
+ }
807
+ opts.push({ v, l });
808
+ }
809
+ }
810
+ return h(
811
+ "select",
812
+ {
813
+ class: "biab-input biab-field__control",
814
+ value: typeof value === "string" ? value : "",
815
+ onChange: (e: Event) =>
816
+ set((e.target as HTMLSelectElement).value),
817
+ },
818
+ [
819
+ h("option", { value: "" }, f.placeholder || "Select time"),
820
+ ...opts.map((o) =>
821
+ h("option", { key: o.v, value: o.v }, o.l),
822
+ ),
823
+ ],
824
+ );
825
+ }
826
+ const struct =
827
+ value && typeof value === "object" && !Array.isArray(value)
828
+ ? (value as Record<string, string>)
829
+ : {};
830
+ const partA = mode === "time_block" ? "start" : "date";
831
+ const partB = mode === "time_block" ? "end" : "time";
832
+ const typeA = mode === "time_block" ? "time" : "date";
833
+ return h("div", { class: "biab-field__grid-2" }, [
834
+ h("div", {}, [
835
+ h(
836
+ "label",
837
+ { class: "biab-field__sublabel" },
838
+ mode === "time_block" ? "Start" : "Date",
839
+ ),
840
+ h("input", {
841
+ type: typeA,
842
+ class: "biab-input",
843
+ value: struct[partA] ?? "",
844
+ onInput: (e: Event) =>
845
+ set({
846
+ ...struct,
847
+ [partA]: (e.target as HTMLInputElement).value,
848
+ }),
849
+ }),
850
+ ]),
851
+ h("div", {}, [
852
+ h(
853
+ "label",
854
+ { class: "biab-field__sublabel" },
855
+ mode === "time_block" ? "End" : "Time",
856
+ ),
857
+ h("input", {
858
+ type: "time",
859
+ class: "biab-input",
860
+ value: struct[partB] ?? "",
861
+ onInput: (e: Event) =>
862
+ set({
863
+ ...struct,
864
+ [partB]: (e.target as HTMLInputElement).value,
865
+ }),
866
+ }),
867
+ ]),
868
+ ]);
869
+ }
870
+ case "date_range": {
871
+ const struct =
872
+ value && typeof value === "object" && !Array.isArray(value)
873
+ ? (value as Record<string, string>)
874
+ : {};
875
+ return h("div", { class: "biab-field__grid-2" }, [
876
+ h("div", {}, [
877
+ h("label", { class: "biab-field__sublabel" }, "Start"),
878
+ h("input", {
879
+ type: "date",
880
+ class: "biab-input",
881
+ value: struct.start ?? "",
882
+ onInput: (e: Event) =>
883
+ set({
884
+ ...struct,
885
+ start: (e.target as HTMLInputElement).value,
886
+ }),
887
+ }),
888
+ ]),
889
+ h("div", {}, [
890
+ h("label", { class: "biab-field__sublabel" }, "End"),
891
+ h("input", {
892
+ type: "date",
893
+ class: "biab-input",
894
+ value: struct.end ?? "",
895
+ onInput: (e: Event) =>
896
+ set({
897
+ ...struct,
898
+ end: (e.target as HTMLInputElement).value,
899
+ }),
900
+ }),
901
+ ]),
902
+ ]);
903
+ }
904
+ case "slider": {
905
+ const min = f.sliderConfig?.min ?? 0;
906
+ const max = f.sliderConfig?.max ?? 100;
907
+ const stepv = f.sliderConfig?.step ?? 1;
908
+ const unit = f.sliderConfig?.unit;
909
+ const fmt = (n: number) =>
910
+ unit === "currency"
911
+ ? `$${n}`
912
+ : unit === "percent"
913
+ ? `${n}%`
914
+ : `${n}`;
915
+ const v = typeof value === "number" ? value : min;
916
+ return h("div", { class: "biab-field__slider" }, [
917
+ h("div", { class: "biab-field__slider-scale" }, [
918
+ h("span", fmt(min)),
919
+ h("span", { class: "biab-field__slider-value" }, fmt(v)),
920
+ h("span", fmt(max)),
921
+ ]),
922
+ h("input", {
923
+ type: "range",
924
+ class: "biab-field__slider-input",
925
+ min,
926
+ max,
927
+ step: stepv,
928
+ value: v,
929
+ onInput: (e: Event) =>
930
+ set(Number((e.target as HTMLInputElement).value)),
931
+ }),
932
+ ]);
933
+ }
934
+ case "star_rating": {
935
+ const max = f.starRatingConfig?.maxStars ?? 5;
936
+ const v = typeof value === "number" ? value : 0;
937
+ return h(
938
+ "div",
939
+ { class: "biab-field__stars" },
940
+ Array.from({ length: max }).map((_, i) =>
941
+ h(
942
+ "button",
943
+ {
944
+ key: i,
945
+ type: "button",
946
+ class: ["biab-field__star", i < v ? "is-on" : ""],
947
+ onClick: () => set(i + 1),
948
+ },
949
+ "★",
950
+ ),
951
+ ),
952
+ );
953
+ }
954
+ case "happiness_rating": {
955
+ const v = typeof value === "number" ? value : -1;
956
+ return h(
957
+ "div",
958
+ { class: "biab-field__happiness" },
959
+ ["😞", "😕", "😐", "🙂", "😄"].map((emoji, i) =>
960
+ h(
961
+ "button",
962
+ {
963
+ key: emoji,
964
+ type: "button",
965
+ class: ["biab-field__happy", i === v ? "is-on" : ""],
966
+ onClick: () => set(i),
967
+ },
968
+ emoji,
969
+ ),
970
+ ),
971
+ );
972
+ }
973
+ case "stepper": {
974
+ const v = typeof value === "number" ? value : 0;
975
+ return h("div", { class: "biab-field__stepper" }, [
976
+ h(
977
+ "button",
978
+ {
979
+ type: "button",
980
+ class: "biab-btn biab-btn--ghost biab-field__stepper-btn",
981
+ onClick: () => set(v - 1),
982
+ },
983
+ "−",
984
+ ),
985
+ h(
986
+ "span",
987
+ { class: "biab-field__stepper-value" },
988
+ String(v),
989
+ ),
990
+ h(
991
+ "button",
992
+ {
993
+ type: "button",
994
+ class: "biab-btn biab-btn--ghost biab-field__stepper-btn",
995
+ onClick: () => set(v + 1),
996
+ },
997
+ "+",
998
+ ),
999
+ ]);
1000
+ }
1001
+ case "address": {
1002
+ const a =
1003
+ value && typeof value === "object" && !Array.isArray(value)
1004
+ ? (value as Record<string, string>)
1005
+ : {};
1006
+ // Per-subfield autofill tokens, suppressed to "off" when autofill
1007
+ // is off for this field (global switch or per-field override).
1008
+ const acOff = autocomplete === "off";
1009
+ const part = (
1010
+ k: string,
1011
+ label: string,
1012
+ ac: string,
1013
+ full = false,
1014
+ ) =>
1015
+ h(
1016
+ "div",
1017
+ {
1018
+ class: full
1019
+ ? "biab-field__addr-full"
1020
+ : "biab-field__addr-half",
1021
+ },
1022
+ [
1023
+ h("label", { class: "biab-field__sublabel" }, label),
1024
+ h("input", {
1025
+ autocomplete: acOff ? "off" : ac,
1026
+ class: "biab-input",
1027
+ value: a[k] ?? "",
1028
+ onInput: (e: Event) =>
1029
+ set({
1030
+ ...a,
1031
+ [k]: (e.target as HTMLInputElement).value,
1032
+ }),
1033
+ }),
1034
+ ],
1035
+ );
1036
+ return h("div", { class: "biab-field__address" }, [
1037
+ part("line1", "Address Line 1", "street-address", true),
1038
+ part("line2", "Address Line 2", "address-line2", true),
1039
+ part("city", "City", "address-level2"),
1040
+ part("state", "State", "address-level1"),
1041
+ part("zip", "Zip", "postal-code", true),
1042
+ ]);
1043
+ }
1044
+ case "color_picker": {
1045
+ const v = typeof value === "string" ? value : "";
1046
+ return h(
1047
+ "div",
1048
+ { class: "biab-field__colors" },
1049
+ (f.options ?? []).map((color) =>
1050
+ h("button", {
1051
+ key: color,
1052
+ type: "button",
1053
+ class: ["biab-field__color", v === color ? "is-on" : ""],
1054
+ style: { backgroundColor: color },
1055
+ title: color,
1056
+ onClick: () => set(color),
1057
+ }),
1058
+ ),
1059
+ );
1060
+ }
1061
+ case "file_upload": {
1062
+ const files = Array.isArray(value)
1063
+ ? (value as { name: string; size: number }[])
1064
+ : [];
1065
+ const maxFiles = f.fileConfig?.maxFiles ?? 1;
1066
+ const allowed = f.fileConfig?.allowedTypes;
1067
+ const remaining = Math.max(maxFiles - files.length, 0);
1068
+ return h("div", { class: "biab-field__file" }, [
1069
+ h("input", {
1070
+ type: "file",
1071
+ class: "biab-field__file-input",
1072
+ multiple: maxFiles > 1,
1073
+ accept:
1074
+ allowed && allowed.length
1075
+ ? allowed.join(",")
1076
+ : undefined,
1077
+ disabled: remaining === 0,
1078
+ onChange: (e: Event) => p.ctx.uploadFile(id, e),
1079
+ }),
1080
+ files.length
1081
+ ? h(
1082
+ "ul",
1083
+ { class: "biab-field__file-list" },
1084
+ files.map((file, idx) =>
1085
+ h(
1086
+ "li",
1087
+ { key: idx, class: "biab-field__file-item" },
1088
+ [
1089
+ h(
1090
+ "span",
1091
+ { class: "biab-field__file-name" },
1092
+ file.name,
1093
+ ),
1094
+ h(
1095
+ "span",
1096
+ { class: "biab-field__file-size" },
1097
+ `${Math.round(file.size / 1024)} KB`,
1098
+ ),
1099
+ h(
1100
+ "button",
1101
+ {
1102
+ type: "button",
1103
+ class: "biab-field__file-remove",
1104
+ "aria-label": "Remove file",
1105
+ onClick: () => p.ctx.removeFile(id, idx),
1106
+ },
1107
+ "✕",
1108
+ ),
1109
+ ],
1110
+ ),
1111
+ ),
1112
+ )
1113
+ : null,
1114
+ ]);
1115
+ }
1116
+ case "phone":
1117
+ return h("input", {
1118
+ autocomplete,
1119
+ type: "tel",
1120
+ class: "biab-input biab-field__control",
1121
+ value: typeof value === "string" ? value : "",
1122
+ placeholder:
1123
+ f.placeholder ||
1124
+ (f.phoneConfig?.international
1125
+ ? "+1 (555) 000-0000"
1126
+ : "(555) 000-0000"),
1127
+ onInput: (e: Event) =>
1128
+ set((e.target as HTMLInputElement).value),
1129
+ });
1130
+ case "email":
1131
+ return h("input", {
1132
+ autocomplete,
1133
+ type: "email",
1134
+ class: "biab-input biab-field__control",
1135
+ value: typeof value === "string" ? value : "",
1136
+ placeholder: f.placeholder || "you@example.com",
1137
+ onInput: (e: Event) =>
1138
+ set((e.target as HTMLInputElement).value),
1139
+ });
1140
+ case "number":
1141
+ return h("input", {
1142
+ type: "number",
1143
+ class: "biab-input biab-field__control",
1144
+ value:
1145
+ typeof value === "number"
1146
+ ? value
1147
+ : typeof value === "string"
1148
+ ? value
1149
+ : "",
1150
+ placeholder: f.placeholder || "",
1151
+ onInput: (e: Event) => {
1152
+ const raw = (e.target as HTMLInputElement).value;
1153
+ set(raw === "" ? "" : Number(raw));
1154
+ },
1155
+ });
1156
+ default:
1157
+ return h("input", {
1158
+ autocomplete,
1159
+ class: "biab-input biab-field__control",
1160
+ value: typeof value === "string" ? value : "",
1161
+ placeholder: f.placeholder || "",
1162
+ onInput: (e: Event) =>
1163
+ set((e.target as HTMLInputElement).value),
1164
+ });
1165
+ }
1166
+ };
1167
+
1168
+ const renderFieldRow = (f: FormCoreField) => {
1169
+ const errored = Boolean(p.ctx.errors[f.id]);
1170
+ const showLabel = !STATIC_NO_LABEL.has(f.type);
1171
+ return h(
1172
+ "div",
1173
+ {
1174
+ "data-field-id": f.id,
1175
+ "data-biab-field": cssKey(f),
1176
+ "data-biab-type": f.type,
1177
+ class: [
1178
+ "biab-field",
1179
+ `biab-field--${f.type}`,
1180
+ f.required ? "biab-field--required" : "",
1181
+ errored ? "biab-field--error" : "",
1182
+ ],
1183
+ },
1184
+ [
1185
+ showLabel
1186
+ ? h("label", { class: "biab-field__label biab-label" }, [
1187
+ f.label || "",
1188
+ f.required
1189
+ ? h("span", { class: "biab-field__required" }, " *")
1190
+ : null,
1191
+ ])
1192
+ : null,
1193
+ renderInput(f),
1194
+ errored
1195
+ ? h(
1196
+ "p",
1197
+ { class: "biab-field__error" },
1198
+ p.ctx.errors[f.id]?.message ?? "This field is required.",
1199
+ )
1200
+ : null,
1201
+ f.helpText
1202
+ ? h("p", { class: "biab-field__help" }, f.helpText)
1203
+ : null,
1204
+ ],
1205
+ );
1206
+ };
1207
+
1208
+ const renderNode = (f: FormCoreField): ReturnType<typeof h> | null => {
1209
+ // input_group
1210
+ if (f.type === "input_group") {
1211
+ const children = (f.children ?? []) as FormCoreField[];
1212
+ const concurrent = f.concurrentReveal === true;
1213
+ const reveal = concurrent
1214
+ ? concurrentRevealCount(children, p.ctx.values)
1215
+ : children.length;
1216
+ const shown = concurrent ? children.slice(0, reveal) : children;
1217
+ return h(
1218
+ "div",
1219
+ {
1220
+ key: f.id,
1221
+ "data-biab-group": cssKey(f),
1222
+ class: ["biab-group", p.flat ? "biab-group--flat" : ""],
1223
+ },
1224
+ [
1225
+ f.label && f.showGroupLabel === true
1226
+ ? h("div", { class: "biab-group__label" }, f.label)
1227
+ : null,
1228
+ h(BiabFieldTree, {
1229
+ nodes: shown,
1230
+ flat: p.flat,
1231
+ ctx: p.ctx,
1232
+ }),
1233
+ ],
1234
+ );
1235
+ }
1236
+ // condition_block
1237
+ if (f.type === "condition_block") {
1238
+ if (!evalCondition(f.conditionRule, p.ctx.values)) return null;
1239
+ return h(BiabFieldTree, {
1240
+ key: f.id,
1241
+ nodes: (f.children ?? []) as FormCoreField[],
1242
+ flat: p.flat,
1243
+ ctx: p.ctx,
1244
+ });
1245
+ }
1246
+ // or_condition
1247
+ if (f.type === "or_condition") {
1248
+ const left = f.children?.[0];
1249
+ const right = f.children?.[1];
1250
+ const errored = Boolean(p.ctx.errors[f.id]);
1251
+ const cfg =
1252
+ (f.orConditionConfig as
1253
+ | { leftLabel?: string; rightLabel?: string }
1254
+ | undefined) ?? {};
1255
+ return h(
1256
+ "div",
1257
+ {
1258
+ key: f.id,
1259
+ class: ["biab-or", errored ? "biab-or--error" : ""],
1260
+ "data-biab-field": cssKey(f),
1261
+ },
1262
+ [
1263
+ f.label
1264
+ ? h("div", { class: "biab-or__label" }, [
1265
+ f.label,
1266
+ h(
1267
+ "span",
1268
+ { class: "biab-or__hint" },
1269
+ " Fill at least one side",
1270
+ ),
1271
+ ])
1272
+ : null,
1273
+ h("div", { class: "biab-or__grid" }, [
1274
+ h("div", { class: "biab-or__side" }, [
1275
+ h(
1276
+ "div",
1277
+ { class: "biab-or__side-label" },
1278
+ cfg.leftLabel || "Option A",
1279
+ ),
1280
+ left
1281
+ ? h(BiabFieldTree, {
1282
+ nodes: [left],
1283
+ flat: p.flat,
1284
+ ctx: p.ctx,
1285
+ })
1286
+ : null,
1287
+ ]),
1288
+ h(
1289
+ "div",
1290
+ { class: "biab-or__divider" },
1291
+ h("span", "OR"),
1292
+ ),
1293
+ h("div", { class: "biab-or__side" }, [
1294
+ h(
1295
+ "div",
1296
+ { class: "biab-or__side-label" },
1297
+ cfg.rightLabel || "Option B",
1298
+ ),
1299
+ right
1300
+ ? h(BiabFieldTree, {
1301
+ nodes: [right],
1302
+ flat: p.flat,
1303
+ ctx: p.ctx,
1304
+ })
1305
+ : null,
1306
+ ]),
1307
+ ]),
1308
+ errored
1309
+ ? h(
1310
+ "p",
1311
+ { class: "biab-or__error" },
1312
+ "Fill in at least one side to continue.",
1313
+ )
1314
+ : null,
1315
+ ],
1316
+ );
1317
+ }
1318
+ // flex_row
1319
+ if (f.type === "flex_row") {
1320
+ const cfg =
1321
+ (f.flexConfig as
1322
+ | { justify?: string; align?: string; gap?: number }
1323
+ | undefined) ?? {};
1324
+ const justify = cfg.justify ?? "start";
1325
+ const align = cfg.align ?? "center";
1326
+ return h(
1327
+ "div",
1328
+ {
1329
+ key: f.id,
1330
+ class: [
1331
+ "biab-flex-row",
1332
+ `biab-flex-row--justify-${justify}`,
1333
+ `biab-flex-row--align-${align}`,
1334
+ ],
1335
+ style: { gap: `${cfg.gap ?? 12}px` },
1336
+ },
1337
+ (f.children ?? []).map((child) =>
1338
+ h(
1339
+ "div",
1340
+ { key: child.id, class: "biab-flex-row__item" },
1341
+ renderFieldRow(child as FormCoreField),
1342
+ ),
1343
+ ),
1344
+ );
1345
+ }
1346
+ // leaf field
1347
+ return renderFieldRow(f);
1348
+ };
1349
+
1350
+ return () =>
1351
+ h(
1352
+ "div",
1353
+ { class: "biab-tree" },
1354
+ p.nodes.map((n) => renderNode(n)),
1355
+ );
1356
+ },
1357
+ });
1358
+ </script>
1359
+
1360
+ <template>
1361
+ <!-- Loading / idle gate -->
1362
+ <div
1363
+ v-if="snap.status === 'loading' || snap.status === 'idle'"
1364
+ class="biab-form biab-form--loading"
1365
+ >
1366
+ <slot name="loading">
1367
+ <p class="biab-form__loading">Loading form…</p>
1368
+ </slot>
1369
+ </div>
1370
+
1371
+ <!-- Schema-load error gate -->
1372
+ <div v-else-if="snap.status === 'error'" class="biab-form biab-form--error">
1373
+ <slot name="error" :message="snap.loadError">
1374
+ <p class="biab-form__load-error">
1375
+ {{ snap.loadError ?? "This form could not be loaded." }}
1376
+ </p>
1377
+ </slot>
1378
+ </div>
1379
+
1380
+ <!-- Success card -->
1381
+ <div
1382
+ v-else-if="effectiveSubmitted && !hideSuccess"
1383
+ :class="rootClass"
1384
+ data-biab-state="submitted"
1385
+ >
1386
+ <slot name="success" :result="effectiveResult">
1387
+ <div class="biab-form__success">
1388
+ <h2 class="biab-form__success-title">
1389
+ {{ snap.title ?? "Thank you" }}
1390
+ </h2>
1391
+ <p class="biab-form__success-body">Your submission was received.</p>
1392
+ </div>
1393
+ </slot>
1394
+ </div>
1395
+
1396
+ <!-- The form -->
1397
+ <div v-else :class="rootClass" style="position: relative">
1398
+ <!-- Org icon -->
1399
+ <img
1400
+ v-if="snap.orgIcon"
1401
+ :src="snap.orgIcon"
1402
+ alt=""
1403
+ class="biab-form__org-icon"
1404
+ style="position: absolute; right: 0; top: 0"
1405
+ />
1406
+
1407
+ <!-- Title / description -->
1408
+ <div v-if="snap.title || snap.description" class="biab-form__header">
1409
+ <h2 v-if="snap.title" class="biab-form__title">{{ snap.title }}</h2>
1410
+ <p v-if="snap.description" class="biab-form__description">
1411
+ {{ snap.description }}
1412
+ </p>
1413
+ </div>
1414
+
1415
+ <!-- Progress: labeled steps -->
1416
+ <div v-if="showStepsProgress" class="biab-progress biab-progress--steps">
1417
+ <div class="biab-progress__heading">Form Progress</div>
1418
+ <div class="biab-progress__track">
1419
+ <template v-for="(s, i) in snap.steps" :key="`progress-${i}`">
1420
+ <div
1421
+ v-if="i > 0"
1422
+ class="biab-progress__connector"
1423
+ :class="{ 'is-done': i <= snap.step }"
1424
+ />
1425
+ <div class="biab-progress__step">
1426
+ <span
1427
+ class="biab-progress__dot"
1428
+ :class="{
1429
+ 'is-current': i === snap.step,
1430
+ 'is-done': i < snap.step,
1431
+ }"
1432
+ >
1433
+ <span v-if="i < snap.step">✓</span>
1434
+ <span v-else>{{ i + 1 }}</span>
1435
+ </span>
1436
+ <span
1437
+ class="biab-progress__label"
1438
+ :class="{ 'is-current': i === snap.step }"
1439
+ >{{ stepTitle(s, i) }}</span
1440
+ >
1441
+ </div>
1442
+ </template>
1443
+ </div>
1444
+ </div>
1445
+
1446
+ <!-- Progress: thin bar -->
1447
+ <div v-if="showBarProgress" class="biab-progress biab-progress--bar">
1448
+ <span class="biab-progress__count"
1449
+ >{{ snap.step + 1 }} / {{ snap.stepCount }}</span
1450
+ >
1451
+ <div class="biab-progress__bar-track">
1452
+ <div
1453
+ class="biab-progress__bar-fill"
1454
+ :style="{ width: `${snap.progressPercent}%` }"
1455
+ />
1456
+ </div>
1457
+ </div>
1458
+
1459
+ <!-- Form-level error summary -->
1460
+ <div
1461
+ v-if="snap.errorList.length > 0"
1462
+ class="biab-form__errors"
1463
+ role="alert"
1464
+ >
1465
+ <div class="biab-form__errors-title">
1466
+ Please complete the highlighted required field{{
1467
+ snap.errorList.length === 1 ? "" : "s"
1468
+ }}.
1469
+ </div>
1470
+ <div class="biab-form__errors-list">
1471
+ {{ snap.errorList.map((er) => er.label).join(", ") }}
1472
+ </div>
1473
+ </div>
1474
+
1475
+ <!-- Field tree for the active step (single-page = all fields) -->
1476
+ <div :key="`step-${snap.step}`" :class="entranceClass">
1477
+ <BiabFieldTree
1478
+ :nodes="snap.visibleFields"
1479
+ :flat="isConsecutive"
1480
+ :ctx="fieldCtx"
1481
+ />
1482
+ </div>
1483
+
1484
+ <!-- Navigation -->
1485
+ <div v-if="isMultiStep" class="biab-nav">
1486
+ <button
1487
+ type="button"
1488
+ class="biab-btn biab-btn--back"
1489
+ :disabled="snap.isFirstStep"
1490
+ @click="controller.back()"
1491
+ >
1492
+ Back
1493
+ </button>
1494
+ <button
1495
+ v-if="!snap.isLastStep"
1496
+ type="button"
1497
+ class="biab-btn biab-btn--next"
1498
+ :disabled="!snap.canAdvance"
1499
+ @click="controller.next()"
1500
+ >
1501
+ Next
1502
+ </button>
1503
+ <button
1504
+ v-else
1505
+ type="button"
1506
+ class="biab-btn biab-btn--submit"
1507
+ :disabled="submitDisabled"
1508
+ @click="handleSubmit()"
1509
+ >
1510
+ {{ snap.submitting ? "Sending…" : submitLabel }}
1511
+ </button>
1512
+ </div>
1513
+
1514
+ <div v-else class="biab-nav">
1515
+ <button
1516
+ type="button"
1517
+ class="biab-btn biab-btn--submit"
1518
+ :disabled="submitDisabled"
1519
+ @click="handleSubmit()"
1520
+ >
1521
+ {{ snap.submitting ? "Sending…" : submitLabel }}
1522
+ </button>
1523
+ </div>
1524
+
1525
+ <!-- Inline failure message (non-validation, e.g. network / plan gate) -->
1526
+ <p
1527
+ v-if="
1528
+ effectiveResult &&
1529
+ !effectiveResult.ok &&
1530
+ snap.errorList.length === 0
1531
+ "
1532
+ class="biab-form__submit-error"
1533
+ role="alert"
1534
+ >
1535
+ {{ effectiveResult.message }}
1536
+ </p>
1537
+ </div>
1538
+ </template>