@businessdash/sdk 0.9.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +899 -0
  3. package/dist/adapters-revalidate.d.ts +44 -0
  4. package/dist/adapters-revalidate.d.ts.map +1 -0
  5. package/dist/adapters-revalidate.js +68 -0
  6. package/dist/adapters-revalidate.js.map +1 -0
  7. package/dist/alpha.d.ts +21 -0
  8. package/dist/alpha.d.ts.map +1 -0
  9. package/dist/alpha.js +21 -0
  10. package/dist/alpha.js.map +1 -0
  11. package/dist/analytics-core.d.ts +34 -0
  12. package/dist/analytics-core.d.ts.map +1 -0
  13. package/dist/analytics-core.js +138 -0
  14. package/dist/analytics-core.js.map +1 -0
  15. package/dist/analytics-server.d.ts +54 -0
  16. package/dist/analytics-server.d.ts.map +1 -0
  17. package/dist/analytics-server.js +78 -0
  18. package/dist/analytics-server.js.map +1 -0
  19. package/dist/attribution-logo.d.ts +2 -0
  20. package/dist/attribution-logo.d.ts.map +1 -0
  21. package/dist/attribution-logo.js +5 -0
  22. package/dist/attribution-logo.js.map +1 -0
  23. package/dist/auth-handler.d.ts +92 -0
  24. package/dist/auth-handler.d.ts.map +1 -0
  25. package/dist/auth-handler.js +277 -0
  26. package/dist/auth-handler.js.map +1 -0
  27. package/dist/biab-forms.css +484 -0
  28. package/dist/cli.d.ts +46 -0
  29. package/dist/cli.d.ts.map +1 -0
  30. package/dist/cli.js +1824 -0
  31. package/dist/cli.js.map +1 -0
  32. package/dist/client.d.ts +1681 -0
  33. package/dist/client.d.ts.map +1 -0
  34. package/dist/client.js +2845 -0
  35. package/dist/client.js.map +1 -0
  36. package/dist/collections.d.ts +13 -0
  37. package/dist/collections.d.ts.map +1 -0
  38. package/dist/collections.js +13 -0
  39. package/dist/collections.js.map +1 -0
  40. package/dist/contracts.d.ts +14609 -0
  41. package/dist/contracts.d.ts.map +1 -0
  42. package/dist/contracts.js +2428 -0
  43. package/dist/contracts.js.map +1 -0
  44. package/dist/data-model-schema.d.ts +962 -0
  45. package/dist/data-model-schema.d.ts.map +1 -0
  46. package/dist/data-model-schema.js +2295 -0
  47. package/dist/data-model-schema.js.map +1 -0
  48. package/dist/distribution.d.ts +38 -0
  49. package/dist/distribution.d.ts.map +1 -0
  50. package/dist/distribution.js +61 -0
  51. package/dist/distribution.js.map +1 -0
  52. package/dist/element.d.ts +121 -0
  53. package/dist/element.d.ts.map +1 -0
  54. package/dist/element.js +276 -0
  55. package/dist/element.js.map +1 -0
  56. package/dist/enums.d.ts +25 -0
  57. package/dist/enums.d.ts.map +1 -0
  58. package/dist/enums.js +38 -0
  59. package/dist/enums.js.map +1 -0
  60. package/dist/errors.d.ts +195 -0
  61. package/dist/errors.d.ts.map +1 -0
  62. package/dist/errors.js +188 -0
  63. package/dist/errors.js.map +1 -0
  64. package/dist/forms-core/autocomplete.d.ts +37 -0
  65. package/dist/forms-core/autocomplete.d.ts.map +1 -0
  66. package/dist/forms-core/autocomplete.js +82 -0
  67. package/dist/forms-core/autocomplete.js.map +1 -0
  68. package/dist/forms-core/business-function.d.ts +61 -0
  69. package/dist/forms-core/business-function.d.ts.map +1 -0
  70. package/dist/forms-core/business-function.js +149 -0
  71. package/dist/forms-core/business-function.js.map +1 -0
  72. package/dist/forms-core/calculation.d.ts +72 -0
  73. package/dist/forms-core/calculation.d.ts.map +1 -0
  74. package/dist/forms-core/calculation.js +103 -0
  75. package/dist/forms-core/calculation.js.map +1 -0
  76. package/dist/forms-core/controller.d.ts +249 -0
  77. package/dist/forms-core/controller.d.ts.map +1 -0
  78. package/dist/forms-core/controller.js +590 -0
  79. package/dist/forms-core/controller.js.map +1 -0
  80. package/dist/forms-core/elements.d.ts +44 -0
  81. package/dist/forms-core/elements.d.ts.map +1 -0
  82. package/dist/forms-core/elements.js +68 -0
  83. package/dist/forms-core/elements.js.map +1 -0
  84. package/dist/forms-core/email-domains.d.ts +10 -0
  85. package/dist/forms-core/email-domains.d.ts.map +1 -0
  86. package/dist/forms-core/email-domains.js +69 -0
  87. package/dist/forms-core/email-domains.js.map +1 -0
  88. package/dist/forms-core/file-rules.d.ts +51 -0
  89. package/dist/forms-core/file-rules.d.ts.map +1 -0
  90. package/dist/forms-core/file-rules.js +105 -0
  91. package/dist/forms-core/file-rules.js.map +1 -0
  92. package/dist/forms-core/formula.d.ts +114 -0
  93. package/dist/forms-core/formula.d.ts.map +1 -0
  94. package/dist/forms-core/formula.js +1170 -0
  95. package/dist/forms-core/formula.js.map +1 -0
  96. package/dist/forms-core/index.d.ts +24 -0
  97. package/dist/forms-core/index.d.ts.map +1 -0
  98. package/dist/forms-core/index.js +28 -0
  99. package/dist/forms-core/index.js.map +1 -0
  100. package/dist/forms-core/units.d.ts +49 -0
  101. package/dist/forms-core/units.d.ts.map +1 -0
  102. package/dist/forms-core/units.js +255 -0
  103. package/dist/forms-core/units.js.map +1 -0
  104. package/dist/forms-core/us-states.d.ts +14 -0
  105. package/dist/forms-core/us-states.d.ts.map +1 -0
  106. package/dist/forms-core/us-states.js +66 -0
  107. package/dist/forms-core/us-states.js.map +1 -0
  108. package/dist/forms-core/validation.d.ts +266 -0
  109. package/dist/forms-core/validation.d.ts.map +1 -0
  110. package/dist/forms-core/validation.js +850 -0
  111. package/dist/forms-core/validation.js.map +1 -0
  112. package/dist/forms.d.ts +19 -0
  113. package/dist/forms.d.ts.map +1 -0
  114. package/dist/forms.js +29 -0
  115. package/dist/forms.js.map +1 -0
  116. package/dist/gallery-types.d.ts +32 -0
  117. package/dist/gallery-types.d.ts.map +1 -0
  118. package/dist/gallery-types.js +2 -0
  119. package/dist/gallery-types.js.map +1 -0
  120. package/dist/graphql.d.ts +97 -0
  121. package/dist/graphql.d.ts.map +1 -0
  122. package/dist/graphql.js +212 -0
  123. package/dist/graphql.js.map +1 -0
  124. package/dist/index.cjs +19448 -0
  125. package/dist/index.d.cts +19 -0
  126. package/dist/index.d.mts +19 -0
  127. package/dist/index.d.ts +19 -0
  128. package/dist/index.d.ts.map +1 -0
  129. package/dist/index.js +20 -0
  130. package/dist/index.js.map +1 -0
  131. package/dist/marketing-schema.d.ts +216 -0
  132. package/dist/marketing-schema.d.ts.map +1 -0
  133. package/dist/marketing-schema.js +200 -0
  134. package/dist/marketing-schema.js.map +1 -0
  135. package/dist/mcp.d.ts +42 -0
  136. package/dist/mcp.d.ts.map +1 -0
  137. package/dist/mcp.js +87 -0
  138. package/dist/mcp.js.map +1 -0
  139. package/dist/native.d.ts +45 -0
  140. package/dist/native.d.ts.map +1 -0
  141. package/dist/native.js +43 -0
  142. package/dist/native.js.map +1 -0
  143. package/dist/next-revalidate.d.ts +43 -0
  144. package/dist/next-revalidate.d.ts.map +1 -0
  145. package/dist/next-revalidate.js +84 -0
  146. package/dist/next-revalidate.js.map +1 -0
  147. package/dist/parallel-pages.d.ts +102 -0
  148. package/dist/parallel-pages.d.ts.map +1 -0
  149. package/dist/parallel-pages.js +108 -0
  150. package/dist/parallel-pages.js.map +1 -0
  151. package/dist/payment-icons.d.ts +66 -0
  152. package/dist/payment-icons.d.ts.map +1 -0
  153. package/dist/payment-icons.js +160 -0
  154. package/dist/payment-icons.js.map +1 -0
  155. package/dist/proxy.d.ts +67 -0
  156. package/dist/proxy.d.ts.map +1 -0
  157. package/dist/proxy.js +97 -0
  158. package/dist/proxy.js.map +1 -0
  159. package/dist/react-analytics.d.ts +39 -0
  160. package/dist/react-analytics.d.ts.map +1 -0
  161. package/dist/react-analytics.js +59 -0
  162. package/dist/react-analytics.js.map +1 -0
  163. package/dist/react-attribution.d.ts +99 -0
  164. package/dist/react-attribution.d.ts.map +1 -0
  165. package/dist/react-attribution.js +52 -0
  166. package/dist/react-attribution.js.map +1 -0
  167. package/dist/react-marketing.d.ts +181 -0
  168. package/dist/react-marketing.d.ts.map +1 -0
  169. package/dist/react-marketing.js +215 -0
  170. package/dist/react-marketing.js.map +1 -0
  171. package/dist/react.d.ts +727 -0
  172. package/dist/react.d.ts.map +1 -0
  173. package/dist/react.js +2418 -0
  174. package/dist/react.js.map +1 -0
  175. package/dist/reserved-paths.d.ts +24 -0
  176. package/dist/reserved-paths.d.ts.map +1 -0
  177. package/dist/reserved-paths.js +60 -0
  178. package/dist/reserved-paths.js.map +1 -0
  179. package/dist/revalidate-verify.d.ts +30 -0
  180. package/dist/revalidate-verify.d.ts.map +1 -0
  181. package/dist/revalidate-verify.js +74 -0
  182. package/dist/revalidate-verify.js.map +1 -0
  183. package/dist/sdk.d.ts +678 -0
  184. package/dist/sdk.d.ts.map +1 -0
  185. package/dist/sdk.js +801 -0
  186. package/dist/sdk.js.map +1 -0
  187. package/dist/seo.d.ts +218 -0
  188. package/dist/seo.d.ts.map +1 -0
  189. package/dist/seo.js +309 -0
  190. package/dist/seo.js.map +1 -0
  191. package/dist/socials.d.ts +47 -0
  192. package/dist/socials.d.ts.map +1 -0
  193. package/dist/socials.js +172 -0
  194. package/dist/socials.js.map +1 -0
  195. package/dist/static.d.ts +14 -0
  196. package/dist/static.d.ts.map +1 -0
  197. package/dist/static.js +14 -0
  198. package/dist/static.js.map +1 -0
  199. package/dist/url-pattern.d.ts +47 -0
  200. package/dist/url-pattern.d.ts.map +1 -0
  201. package/dist/url-pattern.js +124 -0
  202. package/dist/url-pattern.js.map +1 -0
  203. package/dist/vanilla.d.ts +77 -0
  204. package/dist/vanilla.d.ts.map +1 -0
  205. package/dist/vanilla.js +903 -0
  206. package/dist/vanilla.js.map +1 -0
  207. package/openapi.json +13779 -0
  208. package/package.json +204 -0
  209. package/src/adapters-revalidate.ts +88 -0
  210. package/src/alpha.ts +89 -0
  211. package/src/analytics-core.ts +175 -0
  212. package/src/analytics-server.ts +99 -0
  213. package/src/angular/biab-form.component.ts +1443 -0
  214. package/src/angular/index.ts +26 -0
  215. package/src/attribution-logo.ts +5 -0
  216. package/src/auth-handler.ts +352 -0
  217. package/src/cli.ts +2517 -0
  218. package/src/client.ts +3578 -0
  219. package/src/collections.ts +101 -0
  220. package/src/contracts.ts +3390 -0
  221. package/src/data-model-schema.ts +4042 -0
  222. package/src/distribution.ts +73 -0
  223. package/src/element.ts +321 -0
  224. package/src/enums.ts +65 -0
  225. package/src/errors.ts +338 -0
  226. package/src/forms-core/autocomplete.ts +97 -0
  227. package/src/forms-core/business-function.ts +209 -0
  228. package/src/forms-core/calculation.ts +167 -0
  229. package/src/forms-core/controller.ts +922 -0
  230. package/src/forms-core/elements.ts +86 -0
  231. package/src/forms-core/email-domains.ts +68 -0
  232. package/src/forms-core/file-rules.ts +123 -0
  233. package/src/forms-core/formula.ts +1339 -0
  234. package/src/forms-core/index.ts +122 -0
  235. package/src/forms-core/units.ts +308 -0
  236. package/src/forms-core/us-states.ts +68 -0
  237. package/src/forms-core/validation.ts +1037 -0
  238. package/src/forms.ts +63 -0
  239. package/src/gallery-types.ts +31 -0
  240. package/src/graphql.ts +295 -0
  241. package/src/index.ts +505 -0
  242. package/src/marketing-schema.ts +395 -0
  243. package/src/mcp.ts +108 -0
  244. package/src/native.ts +73 -0
  245. package/src/next-revalidate.ts +124 -0
  246. package/src/parallel-pages.ts +192 -0
  247. package/src/payment-icons.ts +205 -0
  248. package/src/proxy.ts +173 -0
  249. package/src/qwik.tsx +1234 -0
  250. package/src/react-analytics.tsx +76 -0
  251. package/src/react-attribution.tsx +192 -0
  252. package/src/react-marketing.tsx +338 -0
  253. package/src/react.tsx +5495 -0
  254. package/src/reserved-paths.ts +65 -0
  255. package/src/revalidate-verify.ts +98 -0
  256. package/src/sdk.ts +1671 -0
  257. package/src/seo.ts +488 -0
  258. package/src/socials.ts +230 -0
  259. package/src/solid.tsx +1466 -0
  260. package/src/static.ts +21 -0
  261. package/src/styles/biab-forms.css +484 -0
  262. package/src/svelte/BiabForm.svelte +1069 -0
  263. package/src/svelte/context.ts +57 -0
  264. package/src/svelte/index.ts +79 -0
  265. package/src/url-pattern.ts +153 -0
  266. package/src/vanilla.ts +1290 -0
  267. package/src/vue/BiabForm.vue +1538 -0
  268. package/src/vue/index.ts +119 -0
package/src/vanilla.ts ADDED
@@ -0,0 +1,1290 @@
1
+ /**
2
+ * `@businessdash/sdk/vanilla` — framework-free imperative DOM binding for `<BiabForm>`.
3
+ *
4
+ * `mountBiabForm(elOrSelector, opts)` mounts a fully-rendered, schema-driven form
5
+ * into any DOM node. It owns nothing of the form's logic — all of that lives in
6
+ * the headless `createFormController` core (`@businessdash/sdk/forms`). This binding
7
+ * is the "render layer": it subscribes to the controller's `snapshot()` and, on
8
+ * every notify, rebuilds the form's DOM by walking `snapshot.visibleFields` and
9
+ * dispatching per field `type`. It is a faithful port of the dashboard renderer
10
+ * (`FormPreview.tsx`) so a drop-in `<biab-form>` reproduces the builder preview.
11
+ *
12
+ * Rendering rules (kept identical to the dashboard):
13
+ * - Light DOM, no shadow root, so the consumer's CSS cascades straight in.
14
+ * - Stable `biab-*` class hooks + `data-biab-*` attributes on every node.
15
+ * - Applies `schema.settings`: animation (none/fade/slide/scale/scroll),
16
+ * multi-step ("consecutive") nav, progress bar/steps, concurrent-reveal
17
+ * gating inside groups, plus the org icon when `showOrgIcon` is set.
18
+ * - condition_block / or_condition / input_group / flex_row layout nodes are
19
+ * walked exactly as the core's validation helpers expect.
20
+ *
21
+ * Compiled to dist (plain TS, no JSX). The web-component binding
22
+ * (`@businessdash/sdk/element`) wraps this same `mountBiabForm`.
23
+ */
24
+
25
+ import {
26
+ type AutocompleteConfig,
27
+ resolveFieldAutocomplete,
28
+ } from "./forms-core/autocomplete.js";
29
+ import {
30
+ type CreateFormControllerOptions,
31
+ concurrentRevealCount,
32
+ createFormController,
33
+ evalCondition,
34
+ type FormController,
35
+ type FormCoreField,
36
+ type FormData,
37
+ type FormSnapshot,
38
+ type FormSubmitOptions,
39
+ } from "./forms-core/index.js";
40
+ import type {
41
+ BiabClient,
42
+ FormFileValue,
43
+ FormSchema,
44
+ FormSubmitResult,
45
+ } from "./sdk.js";
46
+
47
+ // Re-export the core so consumers can reach the controller through this subpath.
48
+ export * from "./forms-core/index.js";
49
+
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+ // Public mount options — the consistent component API across every binding.
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+
54
+ export interface MountBiabFormOptions {
55
+ /** The SDK client. Required unless `schema` is supplied (SSR / pre-fetched).
56
+ * Still used for `submit` / `uploadFile` when both are given. */
57
+ client?: BiabClient;
58
+ /** Form slug. Required when loading via `client`. */
59
+ slug?: string;
60
+ /** Pre-fetched schema (SSR). `load()` adopts it synchronously. */
61
+ schema?: FormSchema;
62
+ /** Label for the final submit button. Default "Submit". */
63
+ submitLabel?: string;
64
+ /** Validate on the server but store nothing (no side effects). */
65
+ dryRun?: boolean;
66
+ /** Extra class(es) added to the root `.biab-form` element. */
67
+ class?: string;
68
+ /** Alias for `class` — accepted for cross-framework parity. */
69
+ className?: string;
70
+ /** Default submit options merged into every submit (call opts win). */
71
+ submitOptions?: FormSubmitOptions;
72
+ /** Global HTML autofill switch. Omit to infer a sensible `autocomplete`
73
+ * token per field; `false` emits `autocomplete="off"` on every input. */
74
+ autoComplete?: boolean;
75
+ /** Per-field autofill overrides keyed by field id: a literal token,
76
+ * `true` (infer), or `false` (off). Wins over the global switch. */
77
+ fieldAutoComplete?: Record<string, string | boolean>;
78
+ /** Fired with the working values when the user submits (before the network
79
+ * round-trip is mapped to a result). Convenience for simple consumers. */
80
+ onSubmit?: (data: FormData) => void;
81
+ /** Fired after a successful submit. */
82
+ onSuccess?: (result: Extract<FormSubmitResult, { ok: true }>) => void;
83
+ /** Fired after a failed submit. */
84
+ onError?: (result: Extract<FormSubmitResult, { ok: false }>) => void;
85
+ }
86
+
87
+ /** Handle returned by `mountBiabForm` — drive or tear down the mounted form. */
88
+ export interface BiabFormInstance {
89
+ /** The underlying headless controller (advanced use). */
90
+ controller: FormController;
91
+ /** Re-fetch (or re-adopt) the schema and re-render. */
92
+ reload(): Promise<void>;
93
+ /** Programmatically submit (same as clicking the submit button). */
94
+ submit(options?: FormSubmitOptions): Promise<FormSubmitResult>;
95
+ /** Reset values / errors / step and re-render. */
96
+ reset(): void;
97
+ /** Unsubscribe, stop the controller, and clear the host element. */
98
+ destroy(): void;
99
+ }
100
+
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+ // Tiny DOM helpers (no framework, no deps).
103
+ // ─────────────────────────────────────────────────────────────────────────────
104
+
105
+ type Attrs = Record<string, string | number | boolean | null | undefined>;
106
+ type Child = Node | string | null | undefined | false;
107
+
108
+ function el(
109
+ tag: string,
110
+ attrs: Attrs = {},
111
+ children: Child[] = [],
112
+ ): HTMLElement {
113
+ const node = document.createElement(tag);
114
+ for (const [k, v] of Object.entries(attrs)) {
115
+ if (v == null || v === false) continue;
116
+ if (k === "class") node.className = String(v);
117
+ else if (v === true) node.setAttribute(k, "");
118
+ else node.setAttribute(k, String(v));
119
+ }
120
+ for (const child of children) {
121
+ if (child == null || child === false) continue;
122
+ node.append(
123
+ typeof child === "string" ? document.createTextNode(child) : child,
124
+ );
125
+ }
126
+ return node;
127
+ }
128
+
129
+ function classNames(
130
+ ...parts: Array<string | false | null | undefined>
131
+ ): string {
132
+ return parts.filter(Boolean).join(" ");
133
+ }
134
+
135
+ /** Escape a value for use inside a `[data-field-id="…"]` attribute selector. */
136
+ function cssEscape(value: string): string {
137
+ const cssApi = (globalThis as { CSS?: { escape?: (v: string) => string } })
138
+ .CSS;
139
+ if (cssApi && typeof cssApi.escape === "function")
140
+ return cssApi.escape(value);
141
+ // Field ids are UUIDs/slugs, but quote-escape defensively as a fallback.
142
+ return value.replace(/["\\]/g, "\\$&");
143
+ }
144
+
145
+ /** Stable, override-friendly CSS hook for a field (mirrors FormPreview.fieldCssKey).
146
+ * Prefers the published output `key`, else the field id. */
147
+ function fieldCssKey(field: FormCoreField): string {
148
+ const explicit =
149
+ (typeof field.outputKey === "string" && field.outputKey.trim()) ||
150
+ (typeof field.key === "string" && field.key.trim());
151
+ return explicit || field.id;
152
+ }
153
+
154
+ const NO_LABEL_TYPES = new Set(["title", "subtitle", "separator", "richtext"]);
155
+
156
+ /** tw-style entrance class hook for the active step (consumer CSS animates these).
157
+ * Matches the `biab-anim--*` convention so orgs can target each animation. */
158
+ function animClass(animation: string, forward: boolean): string {
159
+ switch (animation) {
160
+ case "fade":
161
+ return "biab-anim biab-anim--fade";
162
+ case "scale":
163
+ return "biab-anim biab-anim--scale";
164
+ case "slide":
165
+ return forward
166
+ ? "biab-anim biab-anim--slide biab-anim--slide-forward"
167
+ : "biab-anim biab-anim--slide biab-anim--slide-back";
168
+ default:
169
+ return "";
170
+ }
171
+ }
172
+
173
+ // ─────────────────────────────────────────────────────────────────────────────
174
+ // Per-field input rendering — dispatch by `type`, port of FormPreview.FieldInput.
175
+ // Each input writes back to the controller via `set(value)`.
176
+ // ─────────────────────────────────────────────────────────────────────────────
177
+
178
+ /** Set `autocomplete` only when a token is defined (omit the attribute on
179
+ * `undefined`, never emit `autocomplete=""`). Mirrors the React renderer. */
180
+ function setAutocomplete(el: HTMLElement, token: string | undefined): void {
181
+ if (token !== undefined) el.setAttribute("autocomplete", token);
182
+ }
183
+
184
+ function renderFieldInput(
185
+ field: FormCoreField,
186
+ snapshot: FormSnapshot,
187
+ set: (value: unknown) => void,
188
+ upload: (file: File) => void,
189
+ autoComplete: string | undefined,
190
+ ): HTMLElement | null {
191
+ const value = snapshot.values[field.id];
192
+ const str = typeof value === "string" ? value : "";
193
+ // Address composite suppresses its fixed sub-field tokens to "off" when the
194
+ // field's resolved token is "off" (global switch or per-field override).
195
+ const acOff = autoComplete === "off";
196
+
197
+ switch (field.type) {
198
+ case "title":
199
+ return el("h2", { class: "biab-field__title" }, [
200
+ field.label || "Untitled",
201
+ ]);
202
+ case "subtitle":
203
+ return el("h3", { class: "biab-field__subtitle" }, [field.label || ""]);
204
+ case "separator":
205
+ return el("hr", { class: "biab-field__separator" });
206
+ case "richtext": {
207
+ if (!field.content) return null;
208
+ const div = el("div", { class: "biab-field__richtext biab-prose" });
209
+ // The published `content` is server-sanitized HTML.
210
+ div.innerHTML = field.content;
211
+ return div;
212
+ }
213
+ case "image": {
214
+ const cfg = (field["imageConfig"] ?? {}) as {
215
+ src?: string;
216
+ alt?: string;
217
+ width?: number;
218
+ height?: number;
219
+ };
220
+ if (!cfg.src) return null;
221
+ return el("img", {
222
+ class: "biab-field__image",
223
+ src: cfg.src,
224
+ alt: cfg.alt || "",
225
+ ...(cfg.width ? { width: cfg.width } : {}),
226
+ ...(cfg.height ? { height: cfg.height } : {}),
227
+ });
228
+ }
229
+ case "textarea": {
230
+ const cfg = (field["textareaConfig"] ?? {}) as {
231
+ rows?: number;
232
+ resizable?: boolean;
233
+ };
234
+ const node = el("textarea", {
235
+ class: classNames(
236
+ "biab-textarea",
237
+ cfg.resizable === false && "biab-textarea--fixed",
238
+ ),
239
+ rows: cfg.rows ?? 4,
240
+ placeholder: field.placeholder || "",
241
+ }) as HTMLTextAreaElement;
242
+ setAutocomplete(node, autoComplete);
243
+ node.value = str;
244
+ node.addEventListener("input", () => set(node.value));
245
+ return node;
246
+ }
247
+ case "select": {
248
+ const node = el("select", { class: "biab-select" }) as HTMLSelectElement;
249
+ node.append(
250
+ el("option", { value: "" }, [field.placeholder || "Select..."]),
251
+ );
252
+ for (const opt of field.options ?? []) {
253
+ node.append(el("option", { value: opt }, [opt]));
254
+ }
255
+ node.value = str;
256
+ node.addEventListener("change", () => set(node.value));
257
+ return node;
258
+ }
259
+ case "radio": {
260
+ const wrap = el("div", { class: "biab-radio-group" });
261
+ for (const opt of field.options ?? []) {
262
+ const id = `${field.id}-${opt}`;
263
+ const input = el("input", {
264
+ class: "biab-radio",
265
+ type: "radio",
266
+ name: field.id,
267
+ id,
268
+ value: opt,
269
+ }) as HTMLInputElement;
270
+ input.checked = str === opt;
271
+ input.addEventListener("change", () => set(opt));
272
+ wrap.append(
273
+ el("label", { class: "biab-radio-option", for: id }, [
274
+ input,
275
+ el("span", {}, [opt]),
276
+ ]),
277
+ );
278
+ }
279
+ return wrap;
280
+ }
281
+ case "checkbox": {
282
+ const arr = Array.isArray(value) ? (value as string[]) : [];
283
+ const wrap = el("div", { class: "biab-checkbox-group" });
284
+ for (const opt of field.options ?? []) {
285
+ const input = el("input", {
286
+ class: "biab-checkbox",
287
+ type: "checkbox",
288
+ value: opt,
289
+ }) as HTMLInputElement;
290
+ input.checked = arr.includes(opt);
291
+ input.addEventListener("change", () => {
292
+ const next = input.checked
293
+ ? [...arr, opt]
294
+ : arr.filter((x) => x !== opt);
295
+ set(next);
296
+ });
297
+ wrap.append(
298
+ el("label", { class: "biab-checkbox-option" }, [
299
+ input,
300
+ el("span", {}, [opt]),
301
+ ]),
302
+ );
303
+ }
304
+ return wrap;
305
+ }
306
+ case "boolean": {
307
+ const input = el("input", {
308
+ class: "biab-switch",
309
+ type: "checkbox",
310
+ }) as HTMLInputElement;
311
+ input.checked = value === true;
312
+ input.addEventListener("change", () => set(input.checked));
313
+ return el("label", { class: "biab-boolean" }, [
314
+ input,
315
+ el("span", { class: "biab-boolean__label" }, [
316
+ field.placeholder || "Yes / No",
317
+ ]),
318
+ ]);
319
+ }
320
+ case "date": {
321
+ const node = el("input", {
322
+ class: "biab-input",
323
+ type: "date",
324
+ }) as HTMLInputElement;
325
+ node.value = str;
326
+ node.addEventListener("input", () => set(node.value));
327
+ return node;
328
+ }
329
+ case "email": {
330
+ const node = el("input", {
331
+ class: "biab-input",
332
+ type: "email",
333
+ placeholder: field.placeholder || "you@example.com",
334
+ }) as HTMLInputElement;
335
+ setAutocomplete(node, autoComplete);
336
+ node.value = str;
337
+ node.addEventListener("input", () => set(node.value));
338
+ return node;
339
+ }
340
+ case "phone": {
341
+ const wrap = el("div", { class: "biab-phone" });
342
+ if (field.phoneConfig?.showFlags) {
343
+ wrap.append(
344
+ el("span", { class: "biab-phone__flag", "aria-hidden": "true" }, [
345
+ "\u{1F1FA}\u{1F1F8}",
346
+ ]),
347
+ );
348
+ }
349
+ const node = el("input", {
350
+ class: "biab-input",
351
+ type: "tel",
352
+ placeholder:
353
+ field.placeholder ||
354
+ (field.phoneConfig?.international
355
+ ? "+1 (555) 000-0000"
356
+ : "(555) 000-0000"),
357
+ }) as HTMLInputElement;
358
+ setAutocomplete(node, autoComplete);
359
+ node.value = str;
360
+ node.addEventListener("input", () => set(node.value));
361
+ wrap.append(node);
362
+ return wrap;
363
+ }
364
+ case "number": {
365
+ const node = el("input", {
366
+ class: "biab-input",
367
+ type: "number",
368
+ placeholder: field.placeholder || "",
369
+ }) as HTMLInputElement;
370
+ node.value = typeof value === "number" ? String(value) : str;
371
+ node.addEventListener("input", () => {
372
+ set(node.value === "" ? "" : Number(node.value));
373
+ });
374
+ return node;
375
+ }
376
+ case "stepper": {
377
+ const v = typeof value === "number" ? value : 0;
378
+ const out = el("span", { class: "biab-stepper__value" }, [String(v)]);
379
+ const minus = el(
380
+ "button",
381
+ { class: "biab-btn biab-btn--ghost biab-stepper__btn", type: "button" },
382
+ ["−"],
383
+ );
384
+ const plus = el(
385
+ "button",
386
+ { class: "biab-btn biab-btn--ghost biab-stepper__btn", type: "button" },
387
+ ["+"],
388
+ );
389
+ minus.addEventListener("click", () => set(v - 1));
390
+ plus.addEventListener("click", () => set(v + 1));
391
+ return el("div", { class: "biab-stepper" }, [minus, out, plus]);
392
+ }
393
+ case "slider": {
394
+ const min = field.sliderConfig?.min ?? 0;
395
+ const max = field.sliderConfig?.max ?? 100;
396
+ const step = field.sliderConfig?.step ?? 1;
397
+ const unit = field.sliderConfig?.unit;
398
+ const fmt = (n: number) =>
399
+ unit === "currency" ? `$${n}` : unit === "percent" ? `${n}%` : `${n}`;
400
+ const v = typeof value === "number" ? value : min;
401
+ const readout = el("span", { class: "biab-slider__value" }, [fmt(v)]);
402
+ const input = el("input", {
403
+ class: "biab-slider",
404
+ type: "range",
405
+ min,
406
+ max,
407
+ step,
408
+ }) as HTMLInputElement;
409
+ input.value = String(v);
410
+ input.addEventListener("input", () => {
411
+ const n = Number(input.value);
412
+ readout.textContent = fmt(n);
413
+ set(n);
414
+ });
415
+ return el("div", { class: "biab-slider-wrap" }, [
416
+ el("div", { class: "biab-slider__scale" }, [
417
+ el("span", {}, [fmt(min)]),
418
+ readout,
419
+ el("span", {}, [fmt(max)]),
420
+ ]),
421
+ input,
422
+ ]);
423
+ }
424
+ case "star_rating": {
425
+ const max = field.starRatingConfig?.maxStars ?? 5;
426
+ const v = typeof value === "number" ? value : 0;
427
+ const wrap = el("div", { class: "biab-stars" });
428
+ for (let i = 0; i < max; i++) {
429
+ const btn = el(
430
+ "button",
431
+ {
432
+ class: classNames("biab-star", i < v && "biab-star--filled"),
433
+ type: "button",
434
+ "aria-label": `${i + 1} star${i === 0 ? "" : "s"}`,
435
+ },
436
+ ["★"],
437
+ );
438
+ btn.addEventListener("click", () => set(i + 1));
439
+ wrap.append(btn);
440
+ }
441
+ return wrap;
442
+ }
443
+ case "happiness_rating": {
444
+ const v = typeof value === "number" ? value : -1;
445
+ const wrap = el("div", { class: "biab-happiness" });
446
+ ["\u{1F61E}", "\u{1F615}", "\u{1F610}", "\u{1F642}", "\u{1F604}"].forEach(
447
+ (emoji, i) => {
448
+ const btn = el(
449
+ "button",
450
+ {
451
+ class: classNames(
452
+ "biab-happiness__face",
453
+ i === v && "biab-happiness__face--active",
454
+ ),
455
+ type: "button",
456
+ },
457
+ [emoji],
458
+ );
459
+ btn.addEventListener("click", () => set(i));
460
+ wrap.append(btn);
461
+ },
462
+ );
463
+ return wrap;
464
+ }
465
+ case "color_picker": {
466
+ const v = str;
467
+ const wrap = el("div", { class: "biab-colors" });
468
+ for (const color of field.options ?? []) {
469
+ const btn = el("button", {
470
+ class: classNames(
471
+ "biab-color",
472
+ v === color && "biab-color--selected",
473
+ ),
474
+ type: "button",
475
+ title: color,
476
+ style: `background-color:${color}`,
477
+ });
478
+ btn.addEventListener("click", () => set(color));
479
+ wrap.append(btn);
480
+ }
481
+ return wrap;
482
+ }
483
+ case "date_range": {
484
+ const v =
485
+ value && typeof value === "object"
486
+ ? (value as { start?: string; end?: string })
487
+ : { start: "", end: "" };
488
+ return twoField(
489
+ "Start",
490
+ v.start ?? "",
491
+ "End",
492
+ v.end ?? "",
493
+ "date",
494
+ (start, end) => set({ ...v, start, end }),
495
+ );
496
+ }
497
+ case "time_picker": {
498
+ const mode = field.timeConfig?.mode ?? "simple";
499
+ if (mode === "simple") {
500
+ const format = field.timeConfig?.format ?? "12h";
501
+ const step = Math.max(field.timeConfig?.stepMinutes ?? 30, 1);
502
+ const node = el("select", {
503
+ class: "biab-select",
504
+ }) as HTMLSelectElement;
505
+ node.append(
506
+ el("option", { value: "" }, [field.placeholder || "Select time"]),
507
+ );
508
+ for (let h = 0; h < 24; h++) {
509
+ for (let m = 0; m < 60; m += step) {
510
+ const t = `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
511
+ const ampm = h < 12 ? "AM" : "PM";
512
+ const h12 = h % 12 === 0 ? 12 : h % 12;
513
+ const label =
514
+ format === "24h"
515
+ ? t
516
+ : `${h12}:${String(m).padStart(2, "0")} ${ampm}`;
517
+ node.append(el("option", { value: t }, [label]));
518
+ }
519
+ }
520
+ node.value = str;
521
+ node.addEventListener("change", () => set(node.value));
522
+ return node;
523
+ }
524
+ if (mode === "time_block") {
525
+ const v =
526
+ value && typeof value === "object"
527
+ ? (value as { start?: string; end?: string })
528
+ : { start: "", end: "" };
529
+ return twoField(
530
+ "Start",
531
+ v.start ?? "",
532
+ "End",
533
+ v.end ?? "",
534
+ "time",
535
+ (start, end) => set({ ...v, start, end }),
536
+ );
537
+ }
538
+ const v =
539
+ value && typeof value === "object"
540
+ ? (value as { date?: string; time?: string })
541
+ : { date: "", time: "" };
542
+ return twoField(
543
+ "Date",
544
+ v.date ?? "",
545
+ "Time",
546
+ v.time ?? "",
547
+ ["date", "time"],
548
+ (date, time) => set({ ...v, date, time }),
549
+ );
550
+ }
551
+ case "address": {
552
+ const v = (value && typeof value === "object" ? value : {}) as Record<
553
+ string,
554
+ string
555
+ >;
556
+ const grid = el("div", { class: "biab-address" });
557
+ // Fixed per-sub-field autofill tokens, suppressed to "off" when the
558
+ // composite's resolved token is "off". Mirrors the React renderer.
559
+ const part = (
560
+ key: string,
561
+ label: string,
562
+ token: string,
563
+ full = false,
564
+ ) => {
565
+ const input = el("input", { class: "biab-input" }) as HTMLInputElement;
566
+ setAutocomplete(input, acOff ? "off" : token);
567
+ input.value = v[key] ?? "";
568
+ input.addEventListener("input", () =>
569
+ set({ ...v, [key]: input.value }),
570
+ );
571
+ return el(
572
+ "label",
573
+ {
574
+ class: classNames(
575
+ "biab-address__field",
576
+ full && "biab-address__field--full",
577
+ ),
578
+ },
579
+ [el("span", { class: "biab-address__label" }, [label]), input],
580
+ );
581
+ };
582
+ grid.append(
583
+ part("line1", "Address Line 1", "street-address", true),
584
+ part("line2", "Address Line 2", "address-line2", true),
585
+ part("city", "City", "address-level2"),
586
+ part("state", "State", "address-level1"),
587
+ part("zip", "Zip", "postal-code", true),
588
+ );
589
+ return grid;
590
+ }
591
+ case "file_upload": {
592
+ const files = Array.isArray(value) ? (value as FormFileValue[]) : [];
593
+ const maxFiles = field.fileConfig?.maxFiles ?? 1;
594
+ const allowed = field.fileConfig?.allowedTypes;
595
+ const remaining = Math.max(maxFiles - files.length, 0);
596
+ const wrap = el("div", { class: "biab-file" });
597
+
598
+ const input = el("input", {
599
+ class: "biab-file__input",
600
+ type: "file",
601
+ ...(maxFiles > 1 ? { multiple: true } : {}),
602
+ ...(allowed && allowed.length > 0 ? { accept: allowed.join(",") } : {}),
603
+ style: "display:none",
604
+ }) as HTMLInputElement;
605
+ input.addEventListener("change", () => {
606
+ const picked = input.files;
607
+ if (picked) {
608
+ for (const f of Array.from(picked).slice(0, remaining)) upload(f);
609
+ }
610
+ input.value = "";
611
+ });
612
+
613
+ const trigger = el(
614
+ "button",
615
+ {
616
+ class: "biab-file__trigger",
617
+ type: "button",
618
+ ...(snapshot.uploading || remaining === 0 ? { disabled: true } : {}),
619
+ },
620
+ [
621
+ snapshot.uploading
622
+ ? "Uploading…"
623
+ : remaining === 0
624
+ ? `Maximum ${maxFiles} file${maxFiles === 1 ? "" : "s"} reached`
625
+ : `Click to upload${maxFiles > 1 ? ` (up to ${remaining} more)` : ""}`,
626
+ ],
627
+ );
628
+ trigger.addEventListener("click", () => input.click());
629
+ wrap.append(input, trigger);
630
+
631
+ if (files.length > 0) {
632
+ const list = el("ul", { class: "biab-file__list" });
633
+ files.forEach((f, idx) => {
634
+ const remove = el(
635
+ "button",
636
+ {
637
+ class: "biab-file__remove",
638
+ type: "button",
639
+ "aria-label": "Remove file",
640
+ },
641
+ ["✕"],
642
+ );
643
+ remove.addEventListener("click", () =>
644
+ set(files.filter((_, i) => i !== idx)),
645
+ );
646
+ list.append(
647
+ el("li", { class: "biab-file__item" }, [
648
+ el("span", { class: "biab-file__name" }, [f.name]),
649
+ el("span", { class: "biab-file__size" }, [
650
+ `${Math.round(f.size / 1024)} KB`,
651
+ ]),
652
+ remove,
653
+ ]),
654
+ );
655
+ });
656
+ wrap.append(list);
657
+ }
658
+ return wrap;
659
+ }
660
+ default: {
661
+ const node = el("input", {
662
+ class: "biab-input",
663
+ type: "text",
664
+ placeholder: field.placeholder || "",
665
+ }) as HTMLInputElement;
666
+ setAutocomplete(node, autoComplete);
667
+ node.value = str;
668
+ node.addEventListener("input", () => set(node.value));
669
+ return node;
670
+ }
671
+ }
672
+ }
673
+
674
+ /** Two side-by-side labelled inputs (used for date_range / time_block / appointment). */
675
+ function twoField(
676
+ leftLabel: string,
677
+ leftValue: string,
678
+ rightLabel: string,
679
+ rightValue: string,
680
+ type: string | [string, string],
681
+ onChange: (left: string, right: string) => void,
682
+ ): HTMLElement {
683
+ const [lt, rt] = Array.isArray(type) ? type : [type, type];
684
+ const left = el("input", {
685
+ class: "biab-input",
686
+ type: lt,
687
+ }) as HTMLInputElement;
688
+ const right = el("input", {
689
+ class: "biab-input",
690
+ type: rt,
691
+ }) as HTMLInputElement;
692
+ left.value = leftValue;
693
+ right.value = rightValue;
694
+ left.addEventListener("input", () => onChange(left.value, right.value));
695
+ right.addEventListener("input", () => onChange(left.value, right.value));
696
+ return el("div", { class: "biab-grid biab-grid--2" }, [
697
+ el("label", { class: "biab-grid__cell" }, [
698
+ el("span", { class: "biab-grid__label" }, [leftLabel]),
699
+ left,
700
+ ]),
701
+ el("label", { class: "biab-grid__cell" }, [
702
+ el("span", { class: "biab-grid__label" }, [rightLabel]),
703
+ right,
704
+ ]),
705
+ ]);
706
+ }
707
+
708
+ // ─────────────────────────────────────────────────────────────────────────────
709
+ // Field row + tree walk — port of FormPreview.FieldRow / RenderTree.
710
+ // ─────────────────────────────────────────────────────────────────────────────
711
+
712
+ interface RenderCtx {
713
+ snapshot: FormSnapshot;
714
+ setValue: (id: string, value: unknown) => void;
715
+ upload: (fieldId: string, file: File) => void;
716
+ /** Global + per-field autofill config, threaded from the mount options. */
717
+ autocompleteConfig: AutocompleteConfig;
718
+ }
719
+
720
+ function renderFieldRow(
721
+ field: FormCoreField,
722
+ ctx: RenderCtx,
723
+ flat: boolean,
724
+ ): HTMLElement {
725
+ const errored = Boolean(ctx.snapshot.errors[field.id]);
726
+ const showLabel = !NO_LABEL_TYPES.has(field.type);
727
+ const children: Child[] = [];
728
+
729
+ if (showLabel && field.label) {
730
+ children.push(
731
+ el("label", { class: "biab-field__label" }, [
732
+ field.label,
733
+ field.required
734
+ ? el("span", { class: "biab-field__required" }, [" *"])
735
+ : null,
736
+ ]),
737
+ );
738
+ }
739
+
740
+ const input = renderFieldInput(
741
+ field,
742
+ ctx.snapshot,
743
+ (v) => ctx.setValue(field.id, v),
744
+ (file) => ctx.upload(field.id, file),
745
+ resolveFieldAutocomplete(field, ctx.autocompleteConfig),
746
+ );
747
+ if (input) children.push(input);
748
+
749
+ if (errored) {
750
+ children.push(
751
+ el("p", { class: "biab-field__error" }, [
752
+ ctx.snapshot.errors[field.id]?.message ?? "This field is required.",
753
+ ]),
754
+ );
755
+ }
756
+ if (field.helpText) {
757
+ children.push(el("p", { class: "biab-field__help" }, [field.helpText]));
758
+ }
759
+
760
+ // `flat` is accepted for signature parity with the group renderer; rows look
761
+ // the same flat or nested.
762
+ void flat;
763
+ return el(
764
+ "div",
765
+ {
766
+ class: classNames(
767
+ "biab-field",
768
+ `biab-field--${field.type}`,
769
+ field.required && "biab-field--required",
770
+ errored && "biab-field--error",
771
+ ),
772
+ "data-field-id": field.id,
773
+ "data-biab-field": fieldCssKey(field),
774
+ "data-biab-type": field.type,
775
+ },
776
+ children,
777
+ );
778
+ }
779
+
780
+ /** Recursively render a list of fields, handling every layout/condition node. */
781
+ function renderTree(
782
+ fields: FormCoreField[],
783
+ ctx: RenderCtx,
784
+ flat: boolean,
785
+ ): HTMLElement {
786
+ const container = el("div", { class: "biab-tree" });
787
+
788
+ for (const field of fields) {
789
+ if (field.type === "input_group") {
790
+ const groupChildren = (field.children ?? []) as FormCoreField[];
791
+ const concurrent = field.concurrentReveal === true;
792
+ const revealCount = concurrent
793
+ ? concurrentRevealCount(groupChildren, ctx.snapshot.values)
794
+ : groupChildren.length;
795
+ const visible = concurrent
796
+ ? groupChildren.slice(0, revealCount)
797
+ : groupChildren;
798
+ const groupNode = el(
799
+ "div",
800
+ {
801
+ class: classNames(
802
+ "biab-group",
803
+ flat ? "biab-group--flat" : "biab-group--card",
804
+ ),
805
+ "data-biab-group": fieldCssKey(field),
806
+ },
807
+ [],
808
+ );
809
+ if (field.label && field.showGroupLabel === true) {
810
+ groupNode.append(
811
+ el("div", { class: "biab-group__label" }, [field.label]),
812
+ );
813
+ }
814
+ groupNode.append(renderTree(visible, ctx, flat));
815
+ container.append(groupNode);
816
+ continue;
817
+ }
818
+
819
+ if (field.type === "condition_block") {
820
+ if (!evalCondition(field.conditionRule, ctx.snapshot.values)) continue;
821
+ container.append(
822
+ renderTree((field.children ?? []) as FormCoreField[], ctx, flat),
823
+ );
824
+ continue;
825
+ }
826
+
827
+ if (field.type === "or_condition") {
828
+ const errored = Boolean(ctx.snapshot.errors[field.id]);
829
+ const left = field.children?.[0];
830
+ const right = field.children?.[1];
831
+ const box = el(
832
+ "div",
833
+ { class: classNames("biab-or", errored && "biab-or--error") },
834
+ [],
835
+ );
836
+ if (field.label) {
837
+ box.append(
838
+ el("div", { class: "biab-or__label" }, [
839
+ field.label,
840
+ el("span", { class: "biab-or__hint" }, [" Fill at least one side"]),
841
+ ]),
842
+ );
843
+ }
844
+ const grid = el("div", { class: "biab-or__grid" }, [
845
+ el("div", { class: "biab-or__side" }, [
846
+ el("div", { class: "biab-or__side-label" }, [
847
+ field.orConditionConfig?.leftLabel || "Option A",
848
+ ]),
849
+ left ? renderTree([left], ctx, flat) : null,
850
+ ]),
851
+ el("div", { class: "biab-or__divider" }, [
852
+ el("span", { class: "biab-or__badge" }, ["OR"]),
853
+ ]),
854
+ el("div", { class: "biab-or__side" }, [
855
+ el("div", { class: "biab-or__side-label" }, [
856
+ field.orConditionConfig?.rightLabel || "Option B",
857
+ ]),
858
+ right ? renderTree([right], ctx, flat) : null,
859
+ ]),
860
+ ]);
861
+ box.append(grid);
862
+ if (errored) {
863
+ box.append(
864
+ el("p", { class: "biab-or__error" }, [
865
+ "Fill in at least one side to continue.",
866
+ ]),
867
+ );
868
+ }
869
+ container.append(box);
870
+ continue;
871
+ }
872
+
873
+ if (field.type === "flex_row") {
874
+ const row = el("div", { class: "biab-flex-row" }, []);
875
+ for (const child of (field.children ?? []) as FormCoreField[]) {
876
+ row.append(
877
+ el("div", { class: "biab-flex-row__cell" }, [
878
+ renderFieldRow(child, ctx, flat),
879
+ ]),
880
+ );
881
+ }
882
+ container.append(row);
883
+ continue;
884
+ }
885
+
886
+ container.append(renderFieldRow(field, ctx, flat));
887
+ }
888
+
889
+ return container;
890
+ }
891
+
892
+ // ─────────────────────────────────────────────────────────────────────────────
893
+ // Progress indicators — port of FormPreview's steps / bar variants.
894
+ // ─────────────────────────────────────────────────────────────────────────────
895
+
896
+ function renderProgress(
897
+ snapshot: FormSnapshot,
898
+ onJump: (i: number) => void,
899
+ ): HTMLElement | null {
900
+ const { settings, steps, step, stepCount } = snapshot;
901
+ if (!settings.isConsecutive || !settings.showProgress || stepCount <= 1)
902
+ return null;
903
+
904
+ if (settings.progressStyle === "bar") {
905
+ const fill = el("div", { class: "biab-progress__fill" });
906
+ fill.style.width = `${((step + 1) / stepCount) * 100}%`;
907
+ return el("div", { class: "biab-progress biab-progress--bar" }, [
908
+ el("span", { class: "biab-progress__count" }, [
909
+ `${step + 1} / ${stepCount}`,
910
+ ]),
911
+ el("div", { class: "biab-progress__track" }, [fill]),
912
+ ]);
913
+ }
914
+
915
+ const track = el("div", { class: "biab-progress__steps" });
916
+ steps.forEach((s, i) => {
917
+ const f = s[0];
918
+ const title =
919
+ (typeof f?.stepTitle === "string" && f.stepTitle) ||
920
+ f?.label ||
921
+ `Step ${i + 1}`;
922
+ const done = i < step;
923
+ const current = i === step;
924
+ if (i > 0) {
925
+ track.append(
926
+ el("div", {
927
+ class: classNames(
928
+ "biab-progress__line",
929
+ i <= step && "biab-progress__line--done",
930
+ ),
931
+ }),
932
+ );
933
+ }
934
+ const dot = el(
935
+ "span",
936
+ {
937
+ class: classNames(
938
+ "biab-progress__dot",
939
+ current && "biab-progress__dot--current",
940
+ done && "biab-progress__dot--done",
941
+ ),
942
+ },
943
+ [done ? "✓" : String(i + 1)],
944
+ );
945
+ const stepNode = el(
946
+ "button",
947
+ {
948
+ class: classNames(
949
+ "biab-progress__step",
950
+ current && "biab-progress__step--current",
951
+ ),
952
+ type: "button",
953
+ },
954
+ [dot, el("span", { class: "biab-progress__title" }, [title])],
955
+ );
956
+ stepNode.addEventListener("click", () => onJump(i));
957
+ track.append(stepNode);
958
+ });
959
+
960
+ return el("div", { class: "biab-progress biab-progress--steps" }, [
961
+ el("div", { class: "biab-progress__heading" }, ["Form Progress"]),
962
+ track,
963
+ ]);
964
+ }
965
+
966
+ // ─────────────────────────────────────────────────────────────────────────────
967
+ // mountBiabForm — wire a controller to a host element and render reactively.
968
+ // ─────────────────────────────────────────────────────────────────────────────
969
+
970
+ function resolveHost(elOrSelector: Element | string): HTMLElement {
971
+ const host =
972
+ typeof elOrSelector === "string"
973
+ ? document.querySelector(elOrSelector)
974
+ : elOrSelector;
975
+ if (!host) {
976
+ throw new Error(
977
+ `mountBiabForm: no element found for ${typeof elOrSelector === "string" ? `selector "${elOrSelector}"` : "the provided node"}.`,
978
+ );
979
+ }
980
+ return host as HTMLElement;
981
+ }
982
+
983
+ export function mountBiabForm(
984
+ elOrSelector: Element | string,
985
+ opts: MountBiabFormOptions,
986
+ ): BiabFormInstance {
987
+ const host = resolveHost(elOrSelector);
988
+ const extraClass = opts.class ?? opts.className;
989
+
990
+ // HTML autofill config built from the mount options, threaded to every field.
991
+ const autocompleteConfig: AutocompleteConfig = {
992
+ ...(opts.autoComplete !== undefined ? { enabled: opts.autoComplete } : {}),
993
+ ...(opts.fieldAutoComplete ? { overrides: opts.fieldAutoComplete } : {}),
994
+ };
995
+
996
+ const controllerOptions: CreateFormControllerOptions = {
997
+ ...(opts.client ? { client: opts.client } : {}),
998
+ ...(opts.slug ? { slug: opts.slug } : {}),
999
+ ...(opts.schema ? { schema: opts.schema } : {}),
1000
+ ...(opts.submitOptions ? { submitOptions: opts.submitOptions } : {}),
1001
+ ...(opts.onSuccess ? { onSuccess: opts.onSuccess } : {}),
1002
+ ...(opts.onError ? { onError: opts.onError } : {}),
1003
+ };
1004
+ const controller = createFormController(controllerOptions);
1005
+
1006
+ const doSubmit = (
1007
+ callOpts?: FormSubmitOptions,
1008
+ ): Promise<FormSubmitResult> => {
1009
+ opts.onSubmit?.(controller.snapshot().values);
1010
+ const merged: FormSubmitOptions = {
1011
+ ...(opts.dryRun ? { dryRun: true } : {}),
1012
+ ...callOpts,
1013
+ };
1014
+ return controller.submit(merged);
1015
+ };
1016
+
1017
+ /** Swap the host's content, preserving focus + caret across the rebuild.
1018
+ * Every notify re-renders the whole tree (conditions / reveal change which
1019
+ * fields exist), which would otherwise steal focus mid-typing. We re-locate
1020
+ * the focused input by its owning field row + ordinal and restore the caret. */
1021
+ function swap(root: HTMLElement): void {
1022
+ const active = document.activeElement;
1023
+ let restore: {
1024
+ fieldId: string;
1025
+ index: number;
1026
+ start: number | null;
1027
+ end: number | null;
1028
+ } | null = null;
1029
+ if (
1030
+ active &&
1031
+ host.contains(active) &&
1032
+ (active instanceof HTMLInputElement ||
1033
+ active instanceof HTMLTextAreaElement ||
1034
+ active instanceof HTMLSelectElement)
1035
+ ) {
1036
+ const row = active.closest("[data-field-id]") as HTMLElement | null;
1037
+ const fieldId = row?.getAttribute("data-field-id");
1038
+ if (fieldId && row) {
1039
+ const inputs = Array.from(
1040
+ row.querySelectorAll("input, textarea, select"),
1041
+ );
1042
+ const index = inputs.indexOf(active);
1043
+ const canCaret =
1044
+ active instanceof HTMLInputElement ||
1045
+ active instanceof HTMLTextAreaElement;
1046
+ restore = {
1047
+ fieldId,
1048
+ index,
1049
+ start: canCaret ? active.selectionStart : null,
1050
+ end: canCaret ? active.selectionEnd : null,
1051
+ };
1052
+ }
1053
+ }
1054
+
1055
+ host.replaceChildren(root);
1056
+
1057
+ if (restore) {
1058
+ const row = host.querySelector(
1059
+ `[data-field-id="${cssEscape(restore.fieldId)}"]`,
1060
+ );
1061
+ const inputs = row
1062
+ ? Array.from(row.querySelectorAll("input, textarea, select"))
1063
+ : [];
1064
+ const next = inputs[restore.index] as HTMLElement | undefined;
1065
+ if (next) {
1066
+ next.focus();
1067
+ if (
1068
+ restore.start != null &&
1069
+ (next instanceof HTMLInputElement ||
1070
+ next instanceof HTMLTextAreaElement)
1071
+ ) {
1072
+ try {
1073
+ next.setSelectionRange(restore.start, restore.end ?? restore.start);
1074
+ } catch {
1075
+ // `setSelectionRange` throws on input types that don't support it
1076
+ // (e.g. number / email / date) — focus alone is enough there.
1077
+ }
1078
+ }
1079
+ }
1080
+ }
1081
+ }
1082
+
1083
+ function render(snapshot: FormSnapshot): void {
1084
+ const root = el("div", {
1085
+ class: classNames("biab-form", extraClass),
1086
+ "data-biab-status": snapshot.status,
1087
+ });
1088
+
1089
+ // Org icon (when the form opted into showOrgIcon).
1090
+ if (snapshot.orgIcon) {
1091
+ root.append(
1092
+ el("img", {
1093
+ class: "biab-form__org-icon",
1094
+ src: snapshot.orgIcon,
1095
+ alt: "",
1096
+ }),
1097
+ );
1098
+ }
1099
+
1100
+ // Loading / error states.
1101
+ if (snapshot.status === "loading" || snapshot.status === "idle") {
1102
+ root.append(el("div", { class: "biab-form__loading" }, ["Loading…"]));
1103
+ swap(root);
1104
+ return;
1105
+ }
1106
+ if (snapshot.status === "error") {
1107
+ root.append(
1108
+ el("div", { class: "biab-form__error" }, [
1109
+ snapshot.loadError || "Could not load this form.",
1110
+ ]),
1111
+ );
1112
+ swap(root);
1113
+ return;
1114
+ }
1115
+
1116
+ // Submitted confirmation.
1117
+ if (snapshot.submitted) {
1118
+ root.append(
1119
+ el("div", { class: "biab-form__success" }, [
1120
+ el("p", { class: "biab-form__success-title" }, [
1121
+ "Thanks — we got it.",
1122
+ ]),
1123
+ ]),
1124
+ );
1125
+ swap(root);
1126
+ return;
1127
+ }
1128
+
1129
+ // Header (title / description).
1130
+ if (snapshot.title || snapshot.description) {
1131
+ const header = el("div", { class: "biab-form__header" }, []);
1132
+ if (snapshot.title)
1133
+ header.append(
1134
+ el("h2", { class: "biab-form__title" }, [snapshot.title]),
1135
+ );
1136
+ if (snapshot.description) {
1137
+ header.append(
1138
+ el("p", { class: "biab-form__description" }, [snapshot.description]),
1139
+ );
1140
+ }
1141
+ root.append(header);
1142
+ }
1143
+
1144
+ // Progress indicator.
1145
+ const progress = renderProgress(snapshot, (i) => controller.goToStep(i));
1146
+ if (progress) root.append(progress);
1147
+
1148
+ // Error summary banner.
1149
+ if (snapshot.errorList.length > 0) {
1150
+ root.append(
1151
+ el("div", { class: "biab-form__error-summary" }, [
1152
+ el("div", { class: "biab-form__error-summary-title" }, [
1153
+ `Please complete the highlighted required field${snapshot.errorList.length === 1 ? "" : "s"}.`,
1154
+ ]),
1155
+ el("div", { class: "biab-form__error-summary-list" }, [
1156
+ snapshot.errorList.map((e) => e.label).join(", "),
1157
+ ]),
1158
+ ]),
1159
+ );
1160
+ }
1161
+
1162
+ const ctx: RenderCtx = {
1163
+ snapshot,
1164
+ setValue: (id, v) => controller.setValue(id, v),
1165
+ upload: (fieldId, file) => {
1166
+ void controller.uploadFile(fieldId, file);
1167
+ },
1168
+ autocompleteConfig,
1169
+ };
1170
+
1171
+ // Field tree (animation-wrapped active step).
1172
+ const flat = snapshot.settings.isConsecutive;
1173
+ const stepWrap = el("div", {
1174
+ class: animClass(snapshot.settings.animation, snapshot.forward),
1175
+ "data-biab-step": String(snapshot.step),
1176
+ });
1177
+ if (snapshot.settings.animation === "scroll") {
1178
+ // Reveal each top-level field on scroll. Class is the hook; CSS owns the
1179
+ // transition. We add the visible state once it intersects.
1180
+ for (const vf of snapshot.visibleFields) {
1181
+ const reveal = el("div", { class: "biab-reveal" }, [
1182
+ renderTree([vf], ctx, flat),
1183
+ ]);
1184
+ stepWrap.append(reveal);
1185
+ observeReveal(reveal);
1186
+ }
1187
+ } else {
1188
+ stepWrap.append(renderTree(snapshot.visibleFields, ctx, flat));
1189
+ }
1190
+ root.append(stepWrap);
1191
+
1192
+ // Navigation / submit.
1193
+ root.append(renderNav(snapshot));
1194
+
1195
+ swap(root);
1196
+ }
1197
+
1198
+ function renderNav(snapshot: FormSnapshot): HTMLElement {
1199
+ const submitLabel = opts.submitLabel ?? "Submit";
1200
+
1201
+ if (snapshot.settings.isConsecutive && snapshot.stepCount > 1) {
1202
+ const back = el(
1203
+ "button",
1204
+ {
1205
+ class: "biab-btn biab-btn--ghost biab-btn--back",
1206
+ type: "button",
1207
+ ...(snapshot.isFirstStep ? { disabled: true } : {}),
1208
+ },
1209
+ ["Back"],
1210
+ );
1211
+ back.addEventListener("click", () => controller.back());
1212
+
1213
+ let advance: HTMLElement;
1214
+ if (!snapshot.isLastStep) {
1215
+ advance = el(
1216
+ "button",
1217
+ {
1218
+ class: "biab-btn biab-btn--next",
1219
+ type: "button",
1220
+ ...(snapshot.canAdvance ? {} : { disabled: true }),
1221
+ },
1222
+ ["Next"],
1223
+ );
1224
+ advance.addEventListener("click", () => controller.next());
1225
+ } else {
1226
+ advance = el(
1227
+ "button",
1228
+ {
1229
+ class: "biab-btn biab-btn--submit",
1230
+ type: "button",
1231
+ ...(snapshot.submitting ? { disabled: true } : {}),
1232
+ },
1233
+ [snapshot.submitting ? "Sending…" : submitLabel],
1234
+ );
1235
+ advance.addEventListener("click", () => void doSubmit());
1236
+ }
1237
+ return el("div", { class: "biab-nav" }, [back, advance]);
1238
+ }
1239
+
1240
+ const submit = el(
1241
+ "button",
1242
+ {
1243
+ class: "biab-btn biab-btn--submit",
1244
+ type: "button",
1245
+ ...(snapshot.submitting ? { disabled: true } : {}),
1246
+ },
1247
+ [snapshot.submitting ? "Sending…" : submitLabel],
1248
+ );
1249
+ submit.addEventListener("click", () => void doSubmit());
1250
+ return el("div", { class: "biab-nav" }, [submit]);
1251
+ }
1252
+
1253
+ const unsubscribe = controller.subscribe(render);
1254
+ // Initial paint + load (load() notifies, which re-renders).
1255
+ render(controller.snapshot());
1256
+ void controller.load();
1257
+
1258
+ return {
1259
+ controller,
1260
+ reload: () => controller.load(),
1261
+ submit: doSubmit,
1262
+ reset: () => controller.reset(),
1263
+ destroy: () => {
1264
+ unsubscribe();
1265
+ controller.destroy();
1266
+ host.replaceChildren();
1267
+ },
1268
+ };
1269
+ }
1270
+
1271
+ /** Add the `biab-reveal--shown` hook once an element scrolls into view (scroll anim). */
1272
+ function observeReveal(node: HTMLElement): void {
1273
+ if (typeof IntersectionObserver === "undefined") {
1274
+ node.classList.add("biab-reveal--shown");
1275
+ return;
1276
+ }
1277
+ const obs = new IntersectionObserver(
1278
+ (entries) => {
1279
+ for (const entry of entries) {
1280
+ if (entry.isIntersecting) {
1281
+ node.classList.add("biab-reveal--shown");
1282
+ obs.disconnect();
1283
+ break;
1284
+ }
1285
+ }
1286
+ },
1287
+ { threshold: 0.15, rootMargin: "0px 0px -8% 0px" },
1288
+ );
1289
+ obs.observe(node);
1290
+ }