@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,1443 @@
1
+ /**
2
+ * `@businessdash/sdk/angular` — `<biab-form>` drop-in Angular component.
3
+ *
4
+ * Ship mode: SOURCE. This file is a single standalone Angular component shipped
5
+ * as plain `.ts` (no ng-packagr / APF) and compiled by the CONSUMER's Angular
6
+ * toolchain. It targets Angular v17+ idioms — standalone-by-default, `input()` /
7
+ * `output()` signals, `inject()`, native control flow (`@if` / `@for` /
8
+ * `@switch`), and host bindings in the decorator `host` object — so it slots
9
+ * into a modern Angular app with zero NgModule wiring.
10
+ *
11
+ * It is a thin, idiomatic skin over the framework-agnostic
12
+ * `createFormController` headless core (`@businessdash/sdk/forms`). All form runtime
13
+ * state and behaviour — values, condition-aware visibility, validation,
14
+ * multi-step nav + progress, concurrent reveal, submission + file uploads — live
15
+ * in the core. This component:
16
+ *
17
+ * 1. Creates a controller from `slug` + an injected/`@Input` `BiabClient` (or
18
+ * a pre-fetched `schema` for SSR).
19
+ * 2. Mirrors the controller's `snapshot()` into a single Angular `signal`,
20
+ * re-publishing on every `subscribe` notify (so OnPush change detection
21
+ * fires precisely when the form state changes).
22
+ * 3. Renders the FULL nested field tree from `snapshot.visibleFields` using
23
+ * the `biab-*` light-DOM class convention (no shadow root), so the org's
24
+ * own CSS cascades exactly as it does on the dashboard renderer.
25
+ * 4. Applies `schema.settings` — animation, multi-step + progress
26
+ * (bar/steps), concurrent reveal, and the org icon — so the drop-in
27
+ * reproduces the dashboard preview 1:1.
28
+ *
29
+ * Rendering is a faithful port of the dashboard's `FormPreview.tsx` field-by-
30
+ * field renderer, mapped onto Angular template control flow. Because the tree is
31
+ * recursive (`input_group` / `condition_block` / `or_condition` / `flex_row`
32
+ * with children), the component renders itself recursively via the private
33
+ * `<biab-form-node>` sub-component below — the public surface stays a single
34
+ * `<biab-form>` tag.
35
+ */
36
+
37
+ import {
38
+ ChangeDetectionStrategy,
39
+ Component,
40
+ DestroyRef,
41
+ InjectionToken,
42
+ Input,
43
+ type OnInit,
44
+ computed,
45
+ inject,
46
+ input,
47
+ output,
48
+ signal,
49
+ } from "@angular/core";
50
+
51
+ import {
52
+ type BiabClient,
53
+ type FieldError,
54
+ type FormController,
55
+ type FormCoreField,
56
+ type FormSchema,
57
+ type FormSnapshot,
58
+ type FormSubmitOptions,
59
+ type FormSubmitResult,
60
+ concurrentRevealCount,
61
+ createFormController,
62
+ evalCondition,
63
+ fetchIconNames,
64
+ filterIconNames,
65
+ iconUrl,
66
+ shouldSearchOptions,
67
+ } from "@businessdash/sdk/forms";
68
+
69
+ import {
70
+ type AutocompleteConfig,
71
+ resolveFieldAutocomplete,
72
+ } from "../forms-core/autocomplete.js";
73
+
74
+ // ─────────────────────────────────────────────────────────────────────────────
75
+ // DI — provide a shared BiabClient app-wide so consumers don't repeat it on
76
+ // every `<biab-form>`. Mirrors the React `<DashboardProvider>` ergonomics but
77
+ // uses Angular's idiomatic InjectionToken + provider helper.
78
+ // ─────────────────────────────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Optional DI token carrying the shared {@link BiabClient}. Provide it once at
82
+ * the app root (e.g. via {@link provideBiabClient}) and every `<biab-form>`
83
+ * picks it up automatically — no `[client]` binding required. A `[client]`
84
+ * input still wins per-instance when both are present.
85
+ */
86
+ export const BIAB_CLIENT = new InjectionToken<BiabClient>("BIAB_CLIENT");
87
+
88
+ /**
89
+ * Provider helper for the application config. Drop into `bootstrapApplication`'s
90
+ * `providers` (or a route's `providers`) so `<biab-form slug="…">` works with no
91
+ * per-instance `[client]`:
92
+ *
93
+ * ```ts
94
+ * bootstrapApplication(App, {
95
+ * providers: [provideBiabClient(myBiabClient)],
96
+ * });
97
+ * ```
98
+ */
99
+ export function provideBiabClient(client: BiabClient) {
100
+ return { provide: BIAB_CLIENT, useValue: client };
101
+ }
102
+
103
+ // ─────────────────────────────────────────────────────────────────────────────
104
+ // Recursive field-tree renderer — one node + its subtree. Private to the module;
105
+ // the public API is only `<biab-form>`. Faithful port of FormPreview's
106
+ // `RenderTree` + `FieldRow` + `FieldInput`, using the same `biab-*` classes and
107
+ // `data-biab-*` hooks so org CSS targets the same selectors as the dashboard.
108
+ // ─────────────────────────────────────────────────────────────────────────────
109
+
110
+ /** Stable CSS hook for a field (the `data-biab-field` value): explicit key →
111
+ * camelCase label slug → id. Mirrors FormPreview's `fieldCssKey`. */
112
+ function fieldCssKey(field: FormCoreField): string {
113
+ const explicit =
114
+ (typeof field.key === "string" && field.key.trim()) ||
115
+ (typeof field.outputKey === "string" && field.outputKey.trim()) ||
116
+ "";
117
+ if (explicit) return explicit;
118
+ const lbl = (field.label ?? "").trim();
119
+ if (lbl) {
120
+ const slug = lbl
121
+ .replace(/[^a-zA-Z0-9]+/g, " ")
122
+ .trim()
123
+ .split(/\s+/)
124
+ .filter(Boolean)
125
+ .map((w, i) =>
126
+ i === 0
127
+ ? w.toLowerCase()
128
+ : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(),
129
+ )
130
+ .join("");
131
+ if (slug) return /^[0-9]/.test(slug) ? `_${slug}` : slug;
132
+ }
133
+ return field.id;
134
+ }
135
+
136
+ const NO_LABEL_TYPES = new Set(["title", "subtitle", "separator", "richtext"]);
137
+
138
+ @Component({
139
+ selector: "biab-form-node",
140
+ changeDetection: ChangeDetectionStrategy.OnPush,
141
+ template: `
142
+ @switch (field.type) {
143
+ @case ("input_group") {
144
+ <div class="biab-group" [attr.data-biab-group]="cssKey()">
145
+ @if (field.label && field.showGroupLabel) {
146
+ <div class="biab-group__label">{{ field.label }}</div>
147
+ }
148
+ @for (child of revealedChildren(); track child.id) {
149
+ <biab-form-node
150
+ [field]="child"
151
+ [values]="values"
152
+ [errors]="errors"
153
+ [autocompleteConfig]="autocompleteConfig"
154
+ (valueChange)="valueChange.emit($event)"
155
+ (fileSelected)="fileSelected.emit($event)"
156
+ (imageSelected)="imageSelected.emit($event)"
157
+ />
158
+ }
159
+ </div>
160
+ }
161
+ @case ("condition_block") {
162
+ @if (conditionPasses()) {
163
+ @for (child of children(); track child.id) {
164
+ <biab-form-node
165
+ [field]="child"
166
+ [values]="values"
167
+ [errors]="errors"
168
+ [autocompleteConfig]="autocompleteConfig"
169
+ (valueChange)="valueChange.emit($event)"
170
+ (fileSelected)="fileSelected.emit($event)"
171
+ (imageSelected)="imageSelected.emit($event)"
172
+ />
173
+ }
174
+ }
175
+ }
176
+ @case ("or_condition") {
177
+ <div
178
+ class="biab-or"
179
+ [class.biab-or--error]="isErrored()"
180
+ [attr.data-biab-or]="cssKey()"
181
+ >
182
+ @if (field.label) {
183
+ <div class="biab-or__label">
184
+ {{ field.label }}
185
+ <span class="biab-or__hint">Fill at least one side</span>
186
+ </div>
187
+ }
188
+ <div class="biab-or__grid">
189
+ <div class="biab-or__side">
190
+ <div class="biab-or__side-label">
191
+ {{ orLeftLabel() }}
192
+ </div>
193
+ @if (children()[0]; as left) {
194
+ <biab-form-node
195
+ [field]="left"
196
+ [values]="values"
197
+ [errors]="errors"
198
+ [autocompleteConfig]="autocompleteConfig"
199
+ (valueChange)="valueChange.emit($event)"
200
+ (fileSelected)="fileSelected.emit($event)"
201
+ (imageSelected)="imageSelected.emit($event)"
202
+ />
203
+ }
204
+ </div>
205
+ <div class="biab-or__divider"><span>OR</span></div>
206
+ <div class="biab-or__side">
207
+ <div class="biab-or__side-label">
208
+ {{ orRightLabel() }}
209
+ </div>
210
+ @if (children()[1]; as right) {
211
+ <biab-form-node
212
+ [field]="right"
213
+ [values]="values"
214
+ [errors]="errors"
215
+ [autocompleteConfig]="autocompleteConfig"
216
+ (valueChange)="valueChange.emit($event)"
217
+ (fileSelected)="fileSelected.emit($event)"
218
+ (imageSelected)="imageSelected.emit($event)"
219
+ />
220
+ }
221
+ </div>
222
+ </div>
223
+ @if (isErrored()) {
224
+ <p class="biab-field__error">Fill in at least one side to continue.</p>
225
+ }
226
+ </div>
227
+ }
228
+ @case ("flex_row") {
229
+ <div class="biab-flex-row">
230
+ @for (child of children(); track child.id) {
231
+ <div class="biab-flex-row__cell">
232
+ <biab-form-node
233
+ [field]="child"
234
+ [values]="values"
235
+ [errors]="errors"
236
+ [autocompleteConfig]="autocompleteConfig"
237
+ (valueChange)="valueChange.emit($event)"
238
+ (fileSelected)="fileSelected.emit($event)"
239
+ (imageSelected)="imageSelected.emit($event)"
240
+ />
241
+ </div>
242
+ }
243
+ </div>
244
+ }
245
+ @default {
246
+ <!-- Leaf field row -->
247
+ <div
248
+ class="biab-field"
249
+ [class]="rowClass()"
250
+ [attr.data-field-id]="field.id"
251
+ [attr.data-biab-field]="cssKey()"
252
+ [attr.data-biab-type]="field.type"
253
+ >
254
+ @if (showLabel()) {
255
+ <label class="biab-field__label" [for]="field.id">
256
+ {{ field.label || "" }}
257
+ @if (field.required) {
258
+ <span class="biab-field__required">*</span>
259
+ }
260
+ </label>
261
+ }
262
+
263
+ @switch (field.type) {
264
+ @case ("title") {
265
+ <h2 class="biab-field__title">{{ field.label || "Untitled" }}</h2>
266
+ }
267
+ @case ("subtitle") {
268
+ <h3 class="biab-field__subtitle">{{ field.label || "" }}</h3>
269
+ }
270
+ @case ("separator") {
271
+ <hr class="biab-field__separator" />
272
+ }
273
+ @case ("richtext") {
274
+ <!-- content is org-authored HTML; the host page is trusted same-origin.
275
+ Sanitize upstream if you accept untrusted form authors. -->
276
+ <div class="biab-field__richtext" [innerHTML]="field.content || ''"></div>
277
+ }
278
+ @case ("image") {
279
+ @if (imageSrc()) {
280
+ <img class="biab-field__image" [src]="imageSrc()" alt="" />
281
+ }
282
+ }
283
+ @case ("textarea") {
284
+ <textarea
285
+ class="biab-input biab-input--textarea"
286
+ [id]="field.id"
287
+ [value]="stringValue()"
288
+ [placeholder]="field.placeholder || ''"
289
+ [rows]="3"
290
+ [attr.autocomplete]="autoComplete()"
291
+ (input)="onInput($event)"
292
+ ></textarea>
293
+ }
294
+ @case ("select") {
295
+ @if (searchableOptions()) {
296
+ <!-- ComboBox card, or a list long enough that scrolling it is
297
+ the worse default. A list attribute plus a datalist gives
298
+ native typeahead: no JS, no styling opinion. -->
299
+ <input
300
+ class="biab-input biab-combobox"
301
+ type="text"
302
+ [id]="field.id"
303
+ [attr.list]="'biab-list-' + field.id"
304
+ [placeholder]="field.placeholder || 'Search…'"
305
+ [value]="stringValue()"
306
+ (input)="onInput($event)"
307
+ />
308
+ <datalist [id]="'biab-list-' + field.id">
309
+ @for (opt of field.options || []; track opt) {
310
+ <option [value]="opt"></option>
311
+ }
312
+ </datalist>
313
+ } @else {
314
+ <select
315
+ class="biab-input biab-input--select"
316
+ [id]="field.id"
317
+ [value]="stringValue()"
318
+ (change)="onInput($event)"
319
+ >
320
+ <option value="">{{ field.placeholder || "Select…" }}</option>
321
+ @for (opt of field.options || []; track opt) {
322
+ <option [value]="opt">{{ opt }}</option>
323
+ }
324
+ </select>
325
+ }
326
+ }
327
+ @case ("icon_select") {
328
+ <!-- The value is an icon NAME; art resolves against the
329
+ platform's icon route, so no icon library ships in the
330
+ SDK bundle. -->
331
+ <div class="biab-iconselect">
332
+ <input
333
+ class="biab-input biab-iconselect__search"
334
+ type="text"
335
+ [placeholder]="field.placeholder || 'Search icons…'"
336
+ [value]="iconQuery()"
337
+ (input)="onIconQuery($event)"
338
+ />
339
+ @if (stringValue()) {
340
+ <span class="biab-iconselect__current">
341
+ <img
342
+ [src]="iconSrc(stringValue())"
343
+ [alt]="stringValue()"
344
+ width="16"
345
+ height="16"
346
+ />
347
+ <button
348
+ type="button"
349
+ class="biab-iconselect__clear"
350
+ (click)="setIconValue('')"
351
+ >
352
+ ×
353
+ </button>
354
+ </span>
355
+ }
356
+ <div class="biab-iconselect__grid">
357
+ @for (name of visibleIcons(); track name) {
358
+ <button
359
+ type="button"
360
+ class="biab-iconselect__item"
361
+ [class.is-selected]="stringValue() === name"
362
+ [title]="name"
363
+ [attr.aria-label]="name"
364
+ [attr.aria-pressed]="stringValue() === name"
365
+ (click)="setIconValue(name)"
366
+ >
367
+ <img [src]="iconSrc(name)" alt="" width="16" height="16" />
368
+ </button>
369
+ }
370
+ </div>
371
+ </div>
372
+ }
373
+ @case ("image_input") {
374
+ <!-- One image, either source; the value is a plain URL string. -->
375
+ <div class="biab-imageinput">
376
+ @if (imageSources() === "both") {
377
+ <div class="biab-imageinput__modes">
378
+ <button
379
+ type="button"
380
+ class="biab-imageinput__mode"
381
+ [class.is-active]="imageMode() === 'upload'"
382
+ [attr.aria-pressed]="imageMode() === 'upload'"
383
+ (click)="setImageMode('upload')"
384
+ >
385
+ Upload
386
+ </button>
387
+ <button
388
+ type="button"
389
+ class="biab-imageinput__mode"
390
+ [class.is-active]="imageMode() === 'url'"
391
+ [attr.aria-pressed]="imageMode() === 'url'"
392
+ (click)="setImageMode('url')"
393
+ >
394
+ Paste URL
395
+ </button>
396
+ </div>
397
+ }
398
+ @if (imageMode() === "upload" && imageSources() !== "url") {
399
+ <input
400
+ class="biab-input biab-imageinput__file"
401
+ type="file"
402
+ [attr.accept]="imageAccept()"
403
+ (change)="onImagePick($event)"
404
+ />
405
+ } @else {
406
+ <input
407
+ class="biab-input biab-imageinput__url"
408
+ type="url"
409
+ [placeholder]="
410
+ field.placeholder || 'https://example.com/image.png'
411
+ "
412
+ [value]="stringValue()"
413
+ (input)="onInput($event)"
414
+ />
415
+ }
416
+ @if (showImagePreview() && stringValue()) {
417
+ <img
418
+ class="biab-imageinput__preview"
419
+ [src]="stringValue()"
420
+ alt=""
421
+ />
422
+ }
423
+ </div>
424
+ }
425
+ @case ("radio") {
426
+ <div class="biab-input biab-input--radio">
427
+ @for (opt of field.options || []; track opt) {
428
+ <label class="biab-radio__item">
429
+ <input
430
+ type="radio"
431
+ [name]="field.id"
432
+ [value]="opt"
433
+ [checked]="stringValue() === opt"
434
+ (change)="setValue(opt)"
435
+ />
436
+ <span>{{ opt }}</span>
437
+ </label>
438
+ }
439
+ </div>
440
+ }
441
+ @case ("checkbox") {
442
+ <div class="biab-input biab-input--checkbox">
443
+ @for (opt of field.options || []; track opt) {
444
+ <label class="biab-checkbox__item">
445
+ <input
446
+ type="checkbox"
447
+ [value]="opt"
448
+ [checked]="checkboxChecked(opt)"
449
+ (change)="toggleCheckbox(opt, $event)"
450
+ />
451
+ <span>{{ opt }}</span>
452
+ </label>
453
+ }
454
+ </div>
455
+ }
456
+ @case ("boolean") {
457
+ <label class="biab-input biab-input--boolean">
458
+ <input
459
+ type="checkbox"
460
+ [checked]="booleanValue()"
461
+ (change)="onBoolean($event)"
462
+ />
463
+ <span>{{ field.placeholder || "Yes / No" }}</span>
464
+ </label>
465
+ }
466
+ @case ("date") {
467
+ <input
468
+ class="biab-input biab-input--date"
469
+ type="date"
470
+ [id]="field.id"
471
+ [value]="stringValue()"
472
+ (input)="onInput($event)"
473
+ />
474
+ }
475
+ @case ("email") {
476
+ <input
477
+ class="biab-input biab-input--email"
478
+ type="email"
479
+ [id]="field.id"
480
+ [value]="stringValue()"
481
+ [placeholder]="field.placeholder || 'you@example.com'"
482
+ [attr.autocomplete]="autoComplete()"
483
+ (input)="onInput($event)"
484
+ />
485
+ }
486
+ @case ("phone") {
487
+ <input
488
+ class="biab-input biab-input--phone"
489
+ type="tel"
490
+ [id]="field.id"
491
+ [value]="stringValue()"
492
+ [placeholder]="phonePlaceholder()"
493
+ [attr.autocomplete]="autoComplete()"
494
+ (input)="onInput($event)"
495
+ />
496
+ }
497
+ @case ("number") {
498
+ <input
499
+ class="biab-input biab-input--number"
500
+ type="number"
501
+ [id]="field.id"
502
+ [value]="numberInputValue()"
503
+ [placeholder]="field.placeholder || ''"
504
+ (input)="onNumber($event)"
505
+ />
506
+ }
507
+ @case ("stepper") {
508
+ <div class="biab-input biab-input--stepper">
509
+ <button type="button" class="biab-btn biab-stepper__dec" (click)="bumpStepper(-1)">
510
+
511
+ </button>
512
+ <span class="biab-stepper__value">{{ numberValue() }}</span>
513
+ <button type="button" class="biab-btn biab-stepper__inc" (click)="bumpStepper(1)">
514
+ +
515
+ </button>
516
+ </div>
517
+ }
518
+ @case ("slider") {
519
+ <div class="biab-input biab-input--slider">
520
+ <div class="biab-slider__readout">
521
+ <span>{{ sliderFmt(sliderMin()) }}</span>
522
+ <span class="biab-slider__current">{{ sliderFmt(numberValue()) }}</span>
523
+ <span>{{ sliderFmt(sliderMax()) }}</span>
524
+ </div>
525
+ <input
526
+ type="range"
527
+ [min]="sliderMin()"
528
+ [max]="sliderMax()"
529
+ [step]="sliderStep()"
530
+ [value]="numberValue()"
531
+ (input)="onNumber($event)"
532
+ />
533
+ </div>
534
+ }
535
+ @case ("star_rating") {
536
+ <div class="biab-input biab-input--stars">
537
+ @for (i of starIndexes(); track i) {
538
+ <button
539
+ type="button"
540
+ class="biab-star"
541
+ [class.biab-star--on]="i < numberValue()"
542
+ (click)="setValue(i + 1)"
543
+ [attr.aria-label]="i + 1 + ' stars'"
544
+ >
545
+
546
+ </button>
547
+ }
548
+ </div>
549
+ }
550
+ @case ("happiness_rating") {
551
+ <div class="biab-input biab-input--happiness">
552
+ @for (face of happinessFaces(); track $index) {
553
+ <button
554
+ type="button"
555
+ class="biab-happiness__face"
556
+ [class.biab-happiness__face--on]="$index === numberValue()"
557
+ (click)="setValue($index)"
558
+ >
559
+ {{ face }}
560
+ </button>
561
+ }
562
+ </div>
563
+ }
564
+ @case ("color_picker") {
565
+ <div class="biab-input biab-input--colors">
566
+ @for (color of field.options || []; track color) {
567
+ <button
568
+ type="button"
569
+ class="biab-color"
570
+ [class.biab-color--on]="stringValue() === color"
571
+ [style.backgroundColor]="color"
572
+ [title]="color"
573
+ (click)="setValue(color)"
574
+ ></button>
575
+ }
576
+ </div>
577
+ }
578
+ @case ("date_range") {
579
+ <div class="biab-input biab-input--date-range">
580
+ <label class="biab-subfield">
581
+ <span>Start</span>
582
+ <input type="date" [value]="rangeStart()" (input)="onRange('start', $event)" />
583
+ </label>
584
+ <label class="biab-subfield">
585
+ <span>End</span>
586
+ <input type="date" [value]="rangeEnd()" (input)="onRange('end', $event)" />
587
+ </label>
588
+ </div>
589
+ }
590
+ @case ("time_picker") {
591
+ @switch (timeMode()) {
592
+ @case ("time_block") {
593
+ <div class="biab-input biab-input--time-block">
594
+ <label class="biab-subfield">
595
+ <span>Start</span>
596
+ <input type="time" [value]="rangeStart()" (input)="onRange('start', $event)" />
597
+ </label>
598
+ <label class="biab-subfield">
599
+ <span>End</span>
600
+ <input type="time" [value]="rangeEnd()" (input)="onRange('end', $event)" />
601
+ </label>
602
+ </div>
603
+ }
604
+ @case ("appointment") {
605
+ <div class="biab-input biab-input--appointment">
606
+ <label class="biab-subfield">
607
+ <span>Date</span>
608
+ <input type="date" [value]="apptDate()" (input)="onAppt('date', $event)" />
609
+ </label>
610
+ <label class="biab-subfield">
611
+ <span>Time</span>
612
+ <input type="time" [value]="apptTime()" (input)="onAppt('time', $event)" />
613
+ </label>
614
+ </div>
615
+ }
616
+ @default {
617
+ <input
618
+ class="biab-input biab-input--time"
619
+ type="time"
620
+ [value]="stringValue()"
621
+ (input)="onInput($event)"
622
+ />
623
+ }
624
+ }
625
+ }
626
+ @case ("address") {
627
+ <div class="biab-input biab-input--address">
628
+ <label class="biab-subfield biab-subfield--full">
629
+ <span>Address Line 1</span>
630
+ <input
631
+ [value]="addrField('line1')"
632
+ [attr.autocomplete]="addrAutoComplete('street-address')"
633
+ (input)="onAddr('line1', $event)"
634
+ />
635
+ </label>
636
+ <label class="biab-subfield biab-subfield--full">
637
+ <span>Address Line 2</span>
638
+ <input
639
+ [value]="addrField('line2')"
640
+ [attr.autocomplete]="addrAutoComplete('address-line2')"
641
+ (input)="onAddr('line2', $event)"
642
+ />
643
+ </label>
644
+ <label class="biab-subfield">
645
+ <span>City</span>
646
+ <input
647
+ [value]="addrField('city')"
648
+ [attr.autocomplete]="addrAutoComplete('address-level2')"
649
+ (input)="onAddr('city', $event)"
650
+ />
651
+ </label>
652
+ <label class="biab-subfield">
653
+ <span>State</span>
654
+ <input
655
+ [value]="addrField('state')"
656
+ [attr.autocomplete]="addrAutoComplete('address-level1')"
657
+ (input)="onAddr('state', $event)"
658
+ />
659
+ </label>
660
+ <label class="biab-subfield biab-subfield--full">
661
+ <span>Zip</span>
662
+ <input
663
+ [value]="addrField('zip')"
664
+ [attr.autocomplete]="addrAutoComplete('postal-code')"
665
+ (input)="onAddr('zip', $event)"
666
+ />
667
+ </label>
668
+ </div>
669
+ }
670
+ @case ("file_upload") {
671
+ <div class="biab-input biab-input--file">
672
+ <input
673
+ type="file"
674
+ [multiple]="fileMultiple()"
675
+ [accept]="fileAccept()"
676
+ (change)="onFile($event)"
677
+ />
678
+ @if (fileList().length) {
679
+ <ul class="biab-file__list">
680
+ @for (f of fileList(); track f.storageId) {
681
+ <li class="biab-file__item">{{ f.name }}</li>
682
+ }
683
+ </ul>
684
+ }
685
+ </div>
686
+ }
687
+ @default {
688
+ <!-- text and any plain-text input -->
689
+ <input
690
+ class="biab-input biab-input--text"
691
+ type="text"
692
+ [id]="field.id"
693
+ [value]="stringValue()"
694
+ [placeholder]="field.placeholder || ''"
695
+ [attr.autocomplete]="autoComplete()"
696
+ (input)="onInput($event)"
697
+ />
698
+ }
699
+ }
700
+
701
+ @if (isErrored()) {
702
+ <p class="biab-field__error">{{ errorMessage() }}</p>
703
+ }
704
+ @if (field.helpText) {
705
+ <p class="biab-field__help">{{ field.helpText }}</p>
706
+ }
707
+ </div>
708
+ }
709
+ }
710
+ `,
711
+ })
712
+ export class BiabFormNodeComponent {
713
+ /** The field (subtree root) to render. */
714
+ @Input({ required: true }) field!: FormCoreField;
715
+ /** Working values, keyed by field id (the snapshot's `values`). */
716
+ @Input({ required: true }) values!: Record<string, unknown>;
717
+ /** Field-level errors, keyed by field id (the snapshot's `errors`). */
718
+ @Input({ required: true }) errors!: Record<string, FieldError>;
719
+ /** Autofill config threaded from `<biab-form>` (global switch + per-field
720
+ * overrides). Drives the `autocomplete` attribute on value-bearing inputs. */
721
+ @Input() autocompleteConfig: AutocompleteConfig = {};
722
+
723
+ /** Emits `{ id, value }` when a leaf field changes. */
724
+ readonly valueChange = output<{ id: string; value: unknown }>();
725
+ /** Emits when a file is chosen for a `file_upload` field. */
726
+ readonly fileSelected = output<{ fieldId: string; file: File }>();
727
+ /**
728
+ * Emits when an image is chosen for an `image_input` field. Separate from
729
+ * `fileSelected` because the host handles it differently: a file upload is
730
+ * appended to the field's array, whereas an image resolves to a single URL
731
+ * STRING that replaces the value.
732
+ */
733
+ readonly imageSelected = output<{ fieldId: string; file: File }>();
734
+
735
+ // ── Derived view helpers (kept as plain getters; the parent re-binds inputs
736
+ // on every snapshot, which triggers OnPush CD with fresh references). ──
737
+
738
+ cssKey(): string {
739
+ return fieldCssKey(this.field);
740
+ }
741
+
742
+ /** Resolved HTML autofill token for this field, or `null` to omit the
743
+ * attribute (Angular's `[attr.autocomplete]` drops it on `null`). Honors the
744
+ * global switch, per-field overrides, and schema token; see
745
+ * forms-core/autocomplete.ts. */
746
+ autoComplete(): string | null {
747
+ return resolveFieldAutocomplete(this.field, this.autocompleteConfig) ?? null;
748
+ }
749
+
750
+ /** Whether autofill is forced off for this field — used to suppress the
751
+ * fixed address sub-field tokens to "off". */
752
+ autoCompleteOff(): boolean {
753
+ return this.autoComplete() === "off";
754
+ }
755
+
756
+ /** Fixed token for an address composite sub-field, suppressed to "off" when
757
+ * the field's resolved token is "off". */
758
+ addrAutoComplete(token: string): string {
759
+ return this.autoCompleteOff() ? "off" : token;
760
+ }
761
+
762
+ children(): FormCoreField[] {
763
+ return (this.field.children ?? []) as FormCoreField[];
764
+ }
765
+
766
+ /** input_group concurrent-reveal gating (mirrors FormPreview). */
767
+ revealedChildren(): FormCoreField[] {
768
+ const kids = this.children();
769
+ if (this.field.concurrentReveal !== true) return kids;
770
+ const count = concurrentRevealCount(kids, this.values);
771
+ return kids.slice(0, count);
772
+ }
773
+
774
+ conditionPasses(): boolean {
775
+ return evalCondition(this.field.conditionRule, this.values);
776
+ }
777
+
778
+ orLeftLabel(): string {
779
+ return this.field.orConditionConfig?.leftLabel || "Option A";
780
+ }
781
+ orRightLabel(): string {
782
+ return this.field.orConditionConfig?.rightLabel || "Option B";
783
+ }
784
+
785
+ showLabel(): boolean {
786
+ return !NO_LABEL_TYPES.has(this.field.type);
787
+ }
788
+
789
+ rowClass(): string {
790
+ const parts = [`biab-field--${this.field.type}`];
791
+ if (this.field.required) parts.push("biab-field--required");
792
+ if (this.isErrored()) parts.push("biab-field--error");
793
+ return parts.join(" ");
794
+ }
795
+
796
+ isErrored(): boolean {
797
+ return Boolean(this.errors[this.field.id]);
798
+ }
799
+ errorMessage(): string {
800
+ return this.errors[this.field.id]?.message ?? "This field is required.";
801
+ }
802
+
803
+ // ── Value coercion helpers ──
804
+ private raw(): unknown {
805
+ return this.values[this.field.id];
806
+ }
807
+ stringValue(): string {
808
+ const v = this.raw();
809
+ return typeof v === "string" ? v : "";
810
+ }
811
+ numberValue(): number {
812
+ const v = this.raw();
813
+ return typeof v === "number" ? v : 0;
814
+ }
815
+ numberInputValue(): string {
816
+ const v = this.raw();
817
+ if (typeof v === "number") return String(v);
818
+ return typeof v === "string" ? v : "";
819
+ }
820
+ booleanValue(): boolean {
821
+ return this.raw() === true;
822
+ }
823
+
824
+ setValue(value: unknown): void {
825
+ this.valueChange.emit({ id: this.field.id, value });
826
+ }
827
+
828
+ onInput(event: Event): void {
829
+ const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
830
+ this.setValue(target.value);
831
+ }
832
+ onNumber(event: Event): void {
833
+ const target = event.target as HTMLInputElement;
834
+ this.setValue(target.value === "" ? "" : Number(target.value));
835
+ }
836
+ onBoolean(event: Event): void {
837
+ this.setValue((event.target as HTMLInputElement).checked);
838
+ }
839
+
840
+ // ── select / combobox ──
841
+
842
+ /** Does this dropdown offer search? The ComboBox card forces it on; any list
843
+ * long enough gets it anyway. */
844
+ searchableOptions(): boolean {
845
+ return shouldSearchOptions(
846
+ (this.field.options ?? []).length,
847
+ (this.field as { display?: string }).display,
848
+ );
849
+ }
850
+
851
+ // ── icon_select ──
852
+ // The value is an icon NAME. Art resolves against the platform's icon route
853
+ // rather than bundling an icon set, and the name index is fetched lazily —
854
+ // ~1,600 entries that change only when Lucide does. A curated
855
+ // `iconConfig.allowedIcons` skips the fetch entirely.
856
+
857
+ readonly iconQuery = signal("");
858
+ private readonly iconIndex = signal<string[]>([]);
859
+ private iconIndexRequested = false;
860
+
861
+ onIconQuery(event: Event): void {
862
+ this.iconQuery.set((event.target as HTMLInputElement).value);
863
+ }
864
+
865
+ /** Distinct from `setValue` only so the template reads clearly at the call
866
+ * site; icons are set by click, not by an input event. */
867
+ setIconValue(name: string): void {
868
+ this.setValue(name);
869
+ }
870
+
871
+ iconSrc(name: string): string {
872
+ return iconUrl(name);
873
+ }
874
+
875
+ visibleIcons(): string[] {
876
+ const curated =
877
+ (this.field as { iconConfig?: { allowedIcons?: string[] } }).iconConfig
878
+ ?.allowedIcons ?? [];
879
+ if (curated.length > 0) {
880
+ return filterIconNames(curated, this.iconQuery());
881
+ }
882
+ if (!this.iconIndexRequested) {
883
+ this.iconIndexRequested = true;
884
+ // Resolves to [] on failure — an empty grid is recoverable.
885
+ void fetchIconNames().then((names) => this.iconIndex.set(names));
886
+ }
887
+ return filterIconNames(this.iconIndex(), this.iconQuery());
888
+ }
889
+
890
+ // ── image_input ──
891
+ // One image, from an upload OR a pasted URL; the submitted value is a plain
892
+ // URL string either way.
893
+
894
+ private readonly imageModeSignal = signal<"upload" | "url">("upload");
895
+
896
+ private imageCfg(): {
897
+ allowedTypes?: string[];
898
+ maxSize?: number;
899
+ showPreview?: boolean;
900
+ sources?: "both" | "upload" | "url";
901
+ } {
902
+ return (
903
+ (
904
+ this.field as {
905
+ imageInputConfig?: {
906
+ allowedTypes?: string[];
907
+ maxSize?: number;
908
+ showPreview?: boolean;
909
+ sources?: "both" | "upload" | "url";
910
+ };
911
+ }
912
+ ).imageInputConfig ?? {}
913
+ );
914
+ }
915
+
916
+ imageSources(): "both" | "upload" | "url" {
917
+ return this.imageCfg().sources ?? "both";
918
+ }
919
+ imageMode(): "upload" | "url" {
920
+ // An upload-only or url-only field has no toggle, so honour the config
921
+ // rather than the local mode signal.
922
+ const sources = this.imageSources();
923
+ if (sources !== "both") return sources;
924
+ return this.imageModeSignal();
925
+ }
926
+ setImageMode(mode: "upload" | "url"): void {
927
+ this.imageModeSignal.set(mode);
928
+ }
929
+ imageAccept(): string {
930
+ return (this.imageCfg().allowedTypes ?? ["image/*"]).join(",");
931
+ }
932
+ showImagePreview(): boolean {
933
+ return this.imageCfg().showPreview !== false;
934
+ }
935
+
936
+ /** Emits the picked file; the host uploads it and writes back the URL. */
937
+ onImagePick(event: Event): void {
938
+ const file = (event.target as HTMLInputElement).files?.[0];
939
+ if (!file) return;
940
+ const maxMb = this.imageCfg().maxSize ?? 5;
941
+ if (file.size > maxMb * 1024 * 1024) return;
942
+ this.imageSelected.emit({ fieldId: this.field.id, file });
943
+ }
944
+
945
+ // ── checkbox (multi) ──
946
+ private checkboxArray(): string[] {
947
+ const v = this.raw();
948
+ return Array.isArray(v) ? (v as string[]) : [];
949
+ }
950
+ checkboxChecked(opt: string): boolean {
951
+ return this.checkboxArray().includes(opt);
952
+ }
953
+ toggleCheckbox(opt: string, event: Event): void {
954
+ const checked = (event.target as HTMLInputElement).checked;
955
+ const arr = this.checkboxArray();
956
+ const next = checked ? [...arr, opt] : arr.filter((x) => x !== opt);
957
+ this.setValue(next);
958
+ }
959
+
960
+ // ── stepper ──
961
+ bumpStepper(delta: number): void {
962
+ this.setValue(this.numberValue() + delta);
963
+ }
964
+
965
+ // ── slider ──
966
+ sliderMin(): number {
967
+ return this.field.sliderConfig?.min ?? 0;
968
+ }
969
+ sliderMax(): number {
970
+ return this.field.sliderConfig?.max ?? 100;
971
+ }
972
+ sliderStep(): number {
973
+ return this.field.sliderConfig?.step ?? 1;
974
+ }
975
+ sliderFmt(n: number): string {
976
+ const unit = this.field.sliderConfig?.unit;
977
+ if (unit === "currency") return `$${n}`;
978
+ if (unit === "percent") return `${n}%`;
979
+ return `${n}`;
980
+ }
981
+
982
+ // ── ratings ──
983
+ starIndexes(): number[] {
984
+ const max = this.field.starRatingConfig?.maxStars ?? 5;
985
+ return Array.from({ length: max }, (_, i) => i);
986
+ }
987
+ happinessFaces(): string[] {
988
+ return ["😞", "😕", "😐", "🙂", "😄"];
989
+ }
990
+
991
+ // ── date_range / time_block ──
992
+ private rangeObj(): { start?: string; end?: string } {
993
+ const v = this.raw();
994
+ return v && typeof v === "object" ? (v as { start?: string; end?: string }) : {};
995
+ }
996
+ rangeStart(): string {
997
+ return this.rangeObj().start ?? "";
998
+ }
999
+ rangeEnd(): string {
1000
+ return this.rangeObj().end ?? "";
1001
+ }
1002
+ onRange(part: "start" | "end", event: Event): void {
1003
+ const value = (event.target as HTMLInputElement).value;
1004
+ this.setValue({ ...this.rangeObj(), [part]: value });
1005
+ }
1006
+
1007
+ // ── time_picker (appointment) ──
1008
+ timeMode(): "simple" | "time_block" | "appointment" {
1009
+ return this.field.timeConfig?.mode ?? "simple";
1010
+ }
1011
+ private apptObj(): { date?: string; time?: string } {
1012
+ const v = this.raw();
1013
+ return v && typeof v === "object" ? (v as { date?: string; time?: string }) : {};
1014
+ }
1015
+ apptDate(): string {
1016
+ return this.apptObj().date ?? "";
1017
+ }
1018
+ apptTime(): string {
1019
+ return this.apptObj().time ?? "";
1020
+ }
1021
+ onAppt(part: "date" | "time", event: Event): void {
1022
+ const value = (event.target as HTMLInputElement).value;
1023
+ this.setValue({ ...this.apptObj(), [part]: value });
1024
+ }
1025
+
1026
+ // ── address ──
1027
+ private addrObj(): Record<string, string> {
1028
+ const v = this.raw();
1029
+ return v && typeof v === "object" ? (v as Record<string, string>) : {};
1030
+ }
1031
+ addrField(key: string): string {
1032
+ return this.addrObj()[key] ?? "";
1033
+ }
1034
+ onAddr(key: string, event: Event): void {
1035
+ const value = (event.target as HTMLInputElement).value;
1036
+ this.setValue({ ...this.addrObj(), [key]: value });
1037
+ }
1038
+
1039
+ // ── phone ──
1040
+ phonePlaceholder(): string {
1041
+ if (this.field.placeholder) return this.field.placeholder;
1042
+ return this.field.phoneConfig?.international === true
1043
+ ? "+1 (555) 000-0000"
1044
+ : "(555) 000-0000";
1045
+ }
1046
+
1047
+ // ── image ──
1048
+ imageSrc(): string {
1049
+ const cfg = (this.field as { imageConfig?: { src?: string } }).imageConfig;
1050
+ return cfg?.src ?? "";
1051
+ }
1052
+
1053
+ // ── file_upload ──
1054
+ fileMultiple(): boolean {
1055
+ return (this.field.fileConfig?.maxFiles ?? 1) > 1;
1056
+ }
1057
+ fileAccept(): string {
1058
+ const types = this.field.fileConfig?.allowedTypes;
1059
+ return types && types.length > 0 ? types.join(",") : "";
1060
+ }
1061
+ fileList(): Array<{ storageId: string; name: string }> {
1062
+ const v = this.raw();
1063
+ return Array.isArray(v) ? (v as Array<{ storageId: string; name: string }>) : [];
1064
+ }
1065
+ onFile(event: Event): void {
1066
+ const input = event.target as HTMLInputElement;
1067
+ const file = input.files?.[0];
1068
+ if (file) this.fileSelected.emit({ fieldId: this.field.id, file });
1069
+ // Clear the input so re-picking the same file fires `change` again.
1070
+ input.value = "";
1071
+ }
1072
+ }
1073
+
1074
+ // ─────────────────────────────────────────────────────────────────────────────
1075
+ // Public component — `<biab-form>`. Owns the controller, mirrors its snapshot
1076
+ // into a signal, and orchestrates steps / progress / submit. Renders each
1077
+ // visible top-level node via `<biab-form-node>`.
1078
+ // ─────────────────────────────────────────────────────────────────────────────
1079
+
1080
+ @Component({
1081
+ selector: "biab-form",
1082
+ changeDetection: ChangeDetectionStrategy.OnPush,
1083
+ imports: [BiabFormNodeComponent],
1084
+ host: {
1085
+ class: "biab-form",
1086
+ "[class.biab-form--submitting]": "snapshot().submitting",
1087
+ "[class.biab-form--submitted]": "snapshot().submitted",
1088
+ "[attr.data-biab-animation]": "snapshot().settings.animation",
1089
+ },
1090
+ template: `
1091
+ @let snap = snapshot();
1092
+
1093
+ @if (snap.orgIcon) {
1094
+ <img class="biab-form__org-icon" [src]="snap.orgIcon" alt="" />
1095
+ }
1096
+
1097
+ @switch (snap.status) {
1098
+ @case ("loading") {
1099
+ <div class="biab-form__loading">Loading…</div>
1100
+ }
1101
+ @case ("error") {
1102
+ <div class="biab-form__load-error">
1103
+ {{ snap.loadError || "Couldn't load this form." }}
1104
+ </div>
1105
+ }
1106
+ @default {
1107
+ @if (snap.submitted) {
1108
+ <div class="biab-form__success">
1109
+ {{ successMessage() }}
1110
+ </div>
1111
+ } @else {
1112
+ <form
1113
+ class="biab-form__body"
1114
+ [attr.data-biab-step]="snap.step"
1115
+ (ngSubmit)="onSubmit()"
1116
+ >
1117
+ @if (showSteps()) {
1118
+ <div class="biab-progress biab-progress--steps">
1119
+ <div class="biab-progress__title">Form Progress</div>
1120
+ <ol class="biab-progress__list">
1121
+ @for (s of snap.steps; track $index; let i = $index) {
1122
+ <li
1123
+ class="biab-progress__step"
1124
+ [class.biab-progress__step--current]="i === snap.step"
1125
+ [class.biab-progress__step--done]="i < snap.step"
1126
+ >
1127
+ <span class="biab-progress__index">
1128
+ {{ i < snap.step ? "✓" : i + 1 }}
1129
+ </span>
1130
+ <span class="biab-progress__label">{{ stepTitle(s, i) }}</span>
1131
+ </li>
1132
+ }
1133
+ </ol>
1134
+ </div>
1135
+ }
1136
+
1137
+ @if (showBar()) {
1138
+ <div class="biab-progress biab-progress--bar">
1139
+ <span class="biab-progress__count">
1140
+ {{ snap.step + 1 }} / {{ snap.stepCount }}
1141
+ </span>
1142
+ <div class="biab-progress__track">
1143
+ <div
1144
+ class="biab-progress__fill"
1145
+ [style.width.%]="snap.progressPercent"
1146
+ ></div>
1147
+ </div>
1148
+ </div>
1149
+ }
1150
+
1151
+ @if (snap.errorList.length) {
1152
+ <div class="biab-form__errors" role="alert">
1153
+ <div class="biab-form__errors-title">
1154
+ Please complete the highlighted required field{{
1155
+ snap.errorList.length === 1 ? "" : "s"
1156
+ }}.
1157
+ </div>
1158
+ <div class="biab-form__errors-list">
1159
+ {{ errorLabels() }}
1160
+ </div>
1161
+ </div>
1162
+ }
1163
+
1164
+ <div class="biab-form__fields" [attr.data-biab-direction]="snap.forward ? 'forward' : 'back'">
1165
+ @for (node of snap.visibleFields; track node.id) {
1166
+ <biab-form-node
1167
+ [field]="node"
1168
+ [values]="snap.values"
1169
+ [errors]="snap.errors"
1170
+ [autocompleteConfig]="autocompleteConfig()"
1171
+ (valueChange)="onValueChange($event)"
1172
+ (fileSelected)="onFileSelected($event)"
1173
+ (imageSelected)="onImageSelected($event)"
1174
+ />
1175
+ }
1176
+ </div>
1177
+
1178
+ <div class="biab-nav">
1179
+ @if (isMultiStep()) {
1180
+ <button
1181
+ type="button"
1182
+ class="biab-btn biab-btn--back"
1183
+ [disabled]="snap.isFirstStep"
1184
+ (click)="back()"
1185
+ >
1186
+ Back
1187
+ </button>
1188
+ }
1189
+ @if (isMultiStep() && !snap.isLastStep) {
1190
+ <button
1191
+ type="button"
1192
+ class="biab-btn biab-btn--next"
1193
+ [disabled]="!snap.canAdvance"
1194
+ (click)="next()"
1195
+ >
1196
+ Next
1197
+ </button>
1198
+ } @else {
1199
+ <button
1200
+ type="submit"
1201
+ class="biab-btn biab-btn--submit"
1202
+ [disabled]="snap.submitting || snap.uploading"
1203
+ >
1204
+ {{ snap.submitting ? "Sending…" : submitLabel() }}
1205
+ </button>
1206
+ }
1207
+ </div>
1208
+
1209
+ @if (failureMessage()) {
1210
+ <p class="biab-form__submit-error" role="alert">{{ failureMessage() }}</p>
1211
+ }
1212
+ </form>
1213
+ }
1214
+ }
1215
+ }
1216
+ `,
1217
+ })
1218
+ export class BiabFormComponent implements OnInit {
1219
+ private readonly destroyRef = inject(DestroyRef);
1220
+ /** App-wide client from DI (optional). A `[client]` input wins over it. */
1221
+ private readonly injectedClient = inject(BIAB_CLIENT, { optional: true });
1222
+
1223
+ // ── Inputs (signal inputs; the public, framework-consistent surface) ──
1224
+
1225
+ /** Form slug to load via the client. Required unless `[schema]` is supplied. */
1226
+ readonly slug = input<string | undefined>(undefined);
1227
+ /** Pre-fetched schema (SSR / proxy). When set, the controller adopts it
1228
+ * synchronously and skips the network load. `[client]` is still needed to
1229
+ * submit / upload. */
1230
+ readonly schema = input<FormSchema | undefined>(undefined);
1231
+ /** Per-instance client override. Falls back to the DI-provided client. */
1232
+ readonly client = input<BiabClient | undefined>(undefined);
1233
+ /** Submit button label (final step). Defaults to "Submit". */
1234
+ readonly submitLabel = input<string>("Submit");
1235
+ /** Validate-only submit: nothing is stored, no side effects. */
1236
+ readonly dryRun = input<boolean>(false);
1237
+ /** Default submit options merged into every submit (call opts win). */
1238
+ readonly submitOptions = input<FormSubmitOptions | undefined>(undefined);
1239
+ /** Extra class string appended to the host (alias `className` for parity). */
1240
+ readonly className = input<string>("");
1241
+ /** Global autofill switch. Default (undefined) infers a sensible
1242
+ * `autocomplete` token per field (name/email/tel/address/…); `false` emits
1243
+ * `autocomplete="off"` on every value-bearing input. Bind `[autoComplete]`. */
1244
+ readonly autoComplete = input<boolean | undefined>(undefined);
1245
+ /** Per-field autofill overrides keyed by field id: a literal token, `true`
1246
+ * (infer), or `false` (off). Wins over `autoComplete` and any schema token.
1247
+ * Bind `[fieldAutoComplete]`. */
1248
+ readonly fieldAutoComplete = input<Record<string, string | boolean> | undefined>(
1249
+ undefined,
1250
+ );
1251
+
1252
+ // ── Outputs (framework-consistent: onSubmit / onSuccess / onError) ──
1253
+
1254
+ /** Fires with the raw {@link FormSubmitResult} after every submit attempt. */
1255
+ readonly submitResult = output<FormSubmitResult>();
1256
+ /** Fires only on a successful submit. */
1257
+ readonly success = output<Extract<FormSubmitResult, { ok: true }>>();
1258
+ /** Fires only on a failed submit. */
1259
+ readonly error = output<Extract<FormSubmitResult, { ok: false }>>();
1260
+
1261
+ // ── Snapshot signal — single source of truth for the template ──
1262
+ readonly snapshot = signal<FormSnapshot>(EMPTY_SNAPSHOT);
1263
+
1264
+ /** Autofill config threaded to the field tree, derived from the
1265
+ * `autoComplete` / `fieldAutoComplete` inputs (mirrors react.tsx). */
1266
+ readonly autocompleteConfig = computed<AutocompleteConfig>(() => {
1267
+ const enabled = this.autoComplete();
1268
+ const overrides = this.fieldAutoComplete();
1269
+ return {
1270
+ ...(enabled !== undefined ? { enabled } : {}),
1271
+ ...(overrides ? { overrides } : {}),
1272
+ };
1273
+ });
1274
+
1275
+ private controller: FormController | null = null;
1276
+
1277
+ // Resolve the effective client: per-instance input → DI token.
1278
+ private resolveClient(): BiabClient | undefined {
1279
+ return this.client() ?? this.injectedClient ?? undefined;
1280
+ }
1281
+
1282
+ ngOnInit(): void {
1283
+ const client = this.resolveClient();
1284
+ const schema = this.schema();
1285
+ const slug = this.slug();
1286
+
1287
+ const controller = createFormController({
1288
+ ...(client ? { client } : {}),
1289
+ ...(slug ? { slug } : {}),
1290
+ ...(schema ? { schema } : {}),
1291
+ ...(this.submitOptions() ? { submitOptions: this.submitOptions() } : {}),
1292
+ onSuccess: (result) => this.success.emit(result),
1293
+ onError: (result) => this.error.emit(result),
1294
+ });
1295
+ this.controller = controller;
1296
+
1297
+ // Seed the initial snapshot, then keep the signal in lockstep with the
1298
+ // controller's notify stream. `subscribe` is NOT called on subscribe, so
1299
+ // we pull the current snapshot explicitly first.
1300
+ this.snapshot.set(controller.snapshot());
1301
+ const unsubscribe = controller.subscribe((next) => this.snapshot.set(next));
1302
+
1303
+ this.destroyRef.onDestroy(() => {
1304
+ unsubscribe();
1305
+ controller.destroy();
1306
+ });
1307
+
1308
+ void controller.load();
1309
+ }
1310
+
1311
+ // ── Settings-derived view flags ──
1312
+ private settings() {
1313
+ return this.snapshot().settings;
1314
+ }
1315
+ isMultiStep(): boolean {
1316
+ return this.settings().isConsecutive && this.snapshot().stepCount > 1;
1317
+ }
1318
+ showSteps(): boolean {
1319
+ const s = this.settings();
1320
+ return (
1321
+ this.isMultiStep() && s.showProgress && s.progressStyle !== "bar"
1322
+ );
1323
+ }
1324
+ showBar(): boolean {
1325
+ const s = this.settings();
1326
+ return this.isMultiStep() && s.showProgress && s.progressStyle === "bar";
1327
+ }
1328
+
1329
+ stepTitle(step: FormCoreField[], index: number): string {
1330
+ const first = step[0];
1331
+ return first?.stepTitle || first?.label || `Step ${index + 1}`;
1332
+ }
1333
+
1334
+ errorLabels(): string {
1335
+ return this.snapshot()
1336
+ .errorList.map((e) => e.label)
1337
+ .join(", ");
1338
+ }
1339
+
1340
+ successMessage(): string {
1341
+ const result = this.snapshot().result;
1342
+ if (result?.ok && result.dryRun) return "Looks good — this was a dry run.";
1343
+ return "Thanks — we got your submission.";
1344
+ }
1345
+
1346
+ failureMessage(): string {
1347
+ const result = this.snapshot().result;
1348
+ if (result && !result.ok && result.reason !== "client_validation_failed") {
1349
+ return result.message;
1350
+ }
1351
+ return "";
1352
+ }
1353
+
1354
+ // ── Controller delegation ──
1355
+ onValueChange(change: { id: string; value: unknown }): void {
1356
+ this.controller?.setValue(change.id, change.value);
1357
+ }
1358
+
1359
+ async onFileSelected(picked: { fieldId: string; file: File }): Promise<void> {
1360
+ try {
1361
+ await this.controller?.uploadFile(picked.fieldId, picked.file, {
1362
+ filename: picked.file.name,
1363
+ });
1364
+ } catch {
1365
+ // uploadFile throws on plan-gate/transfer error; the snapshot's
1366
+ // `uploading` flag resets in its finally. Surfacing a per-field upload
1367
+ // error is left to the org's CSS / a future enhancement.
1368
+ }
1369
+ }
1370
+
1371
+ /**
1372
+ * `image_input` — upload, then store the resulting URL as the field's value.
1373
+ * Unlike `file_upload` (whose value is an array of file records), an image
1374
+ * input's value is a single URL STRING, so the upload result replaces it.
1375
+ */
1376
+ async onImageSelected(picked: { fieldId: string; file: File }): Promise<void> {
1377
+ try {
1378
+ const uploaded = await this.controller?.uploadFile(
1379
+ picked.fieldId,
1380
+ picked.file,
1381
+ { filename: picked.file.name },
1382
+ );
1383
+ const url = (uploaded as { url?: string } | undefined)?.url;
1384
+ if (url) this.controller?.setValue(picked.fieldId, url);
1385
+ } catch {
1386
+ // Same posture as onFileSelected — the snapshot resets `uploading`.
1387
+ }
1388
+ }
1389
+
1390
+ next(): void {
1391
+ this.controller?.next();
1392
+ }
1393
+ back(): void {
1394
+ this.controller?.back();
1395
+ }
1396
+
1397
+ async onSubmit(): Promise<void> {
1398
+ if (!this.controller) return;
1399
+ const opts: FormSubmitOptions = {
1400
+ ...this.submitOptions(),
1401
+ ...(this.dryRun() ? { dryRun: true } : {}),
1402
+ };
1403
+ const result = await this.controller.submit(opts);
1404
+ this.submitResult.emit(result);
1405
+ }
1406
+ }
1407
+
1408
+ // A valid pre-load snapshot so the template never reads `null`. Mirrors the
1409
+ // controller's own seeded `buildSnapshot()` defaults.
1410
+ const EMPTY_SNAPSHOT: FormSnapshot = {
1411
+ status: "idle",
1412
+ loadError: null,
1413
+ schema: null,
1414
+ fieldDefs: [],
1415
+ fields: [],
1416
+ settings: {
1417
+ animation: "none",
1418
+ isConsecutive: false,
1419
+ showProgress: false,
1420
+ progressStyle: "steps",
1421
+ },
1422
+ orgIcon: null,
1423
+ title: null,
1424
+ description: null,
1425
+ values: {},
1426
+ errors: {},
1427
+ errorList: [],
1428
+ steps: [[]],
1429
+ step: 0,
1430
+ stepCount: 1,
1431
+ isFirstStep: true,
1432
+ isLastStep: true,
1433
+ visibleFields: [],
1434
+ progress: 0,
1435
+ progressPercent: 0,
1436
+ canAdvance: true,
1437
+ canSubmit: true,
1438
+ forward: true,
1439
+ submitting: false,
1440
+ uploading: false,
1441
+ result: null,
1442
+ submitted: false,
1443
+ };