@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.
- package/LICENSE +21 -0
- package/README.md +899 -0
- package/dist/adapters-revalidate.d.ts +44 -0
- package/dist/adapters-revalidate.d.ts.map +1 -0
- package/dist/adapters-revalidate.js +68 -0
- package/dist/adapters-revalidate.js.map +1 -0
- package/dist/alpha.d.ts +21 -0
- package/dist/alpha.d.ts.map +1 -0
- package/dist/alpha.js +21 -0
- package/dist/alpha.js.map +1 -0
- package/dist/analytics-core.d.ts +34 -0
- package/dist/analytics-core.d.ts.map +1 -0
- package/dist/analytics-core.js +138 -0
- package/dist/analytics-core.js.map +1 -0
- package/dist/analytics-server.d.ts +54 -0
- package/dist/analytics-server.d.ts.map +1 -0
- package/dist/analytics-server.js +78 -0
- package/dist/analytics-server.js.map +1 -0
- package/dist/attribution-logo.d.ts +2 -0
- package/dist/attribution-logo.d.ts.map +1 -0
- package/dist/attribution-logo.js +5 -0
- package/dist/attribution-logo.js.map +1 -0
- package/dist/auth-handler.d.ts +92 -0
- package/dist/auth-handler.d.ts.map +1 -0
- package/dist/auth-handler.js +277 -0
- package/dist/auth-handler.js.map +1 -0
- package/dist/biab-forms.css +484 -0
- package/dist/cli.d.ts +46 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1824 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +1681 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +2845 -0
- package/dist/client.js.map +1 -0
- package/dist/collections.d.ts +13 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +13 -0
- package/dist/collections.js.map +1 -0
- package/dist/contracts.d.ts +14609 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +2428 -0
- package/dist/contracts.js.map +1 -0
- package/dist/data-model-schema.d.ts +962 -0
- package/dist/data-model-schema.d.ts.map +1 -0
- package/dist/data-model-schema.js +2295 -0
- package/dist/data-model-schema.js.map +1 -0
- package/dist/distribution.d.ts +38 -0
- package/dist/distribution.d.ts.map +1 -0
- package/dist/distribution.js +61 -0
- package/dist/distribution.js.map +1 -0
- package/dist/element.d.ts +121 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +276 -0
- package/dist/element.js.map +1 -0
- package/dist/enums.d.ts +25 -0
- package/dist/enums.d.ts.map +1 -0
- package/dist/enums.js +38 -0
- package/dist/enums.js.map +1 -0
- package/dist/errors.d.ts +195 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +188 -0
- package/dist/errors.js.map +1 -0
- package/dist/forms-core/autocomplete.d.ts +37 -0
- package/dist/forms-core/autocomplete.d.ts.map +1 -0
- package/dist/forms-core/autocomplete.js +82 -0
- package/dist/forms-core/autocomplete.js.map +1 -0
- package/dist/forms-core/business-function.d.ts +61 -0
- package/dist/forms-core/business-function.d.ts.map +1 -0
- package/dist/forms-core/business-function.js +149 -0
- package/dist/forms-core/business-function.js.map +1 -0
- package/dist/forms-core/calculation.d.ts +72 -0
- package/dist/forms-core/calculation.d.ts.map +1 -0
- package/dist/forms-core/calculation.js +103 -0
- package/dist/forms-core/calculation.js.map +1 -0
- package/dist/forms-core/controller.d.ts +249 -0
- package/dist/forms-core/controller.d.ts.map +1 -0
- package/dist/forms-core/controller.js +590 -0
- package/dist/forms-core/controller.js.map +1 -0
- package/dist/forms-core/elements.d.ts +44 -0
- package/dist/forms-core/elements.d.ts.map +1 -0
- package/dist/forms-core/elements.js +68 -0
- package/dist/forms-core/elements.js.map +1 -0
- package/dist/forms-core/email-domains.d.ts +10 -0
- package/dist/forms-core/email-domains.d.ts.map +1 -0
- package/dist/forms-core/email-domains.js +69 -0
- package/dist/forms-core/email-domains.js.map +1 -0
- package/dist/forms-core/file-rules.d.ts +51 -0
- package/dist/forms-core/file-rules.d.ts.map +1 -0
- package/dist/forms-core/file-rules.js +105 -0
- package/dist/forms-core/file-rules.js.map +1 -0
- package/dist/forms-core/formula.d.ts +114 -0
- package/dist/forms-core/formula.d.ts.map +1 -0
- package/dist/forms-core/formula.js +1170 -0
- package/dist/forms-core/formula.js.map +1 -0
- package/dist/forms-core/index.d.ts +24 -0
- package/dist/forms-core/index.d.ts.map +1 -0
- package/dist/forms-core/index.js +28 -0
- package/dist/forms-core/index.js.map +1 -0
- package/dist/forms-core/units.d.ts +49 -0
- package/dist/forms-core/units.d.ts.map +1 -0
- package/dist/forms-core/units.js +255 -0
- package/dist/forms-core/units.js.map +1 -0
- package/dist/forms-core/us-states.d.ts +14 -0
- package/dist/forms-core/us-states.d.ts.map +1 -0
- package/dist/forms-core/us-states.js +66 -0
- package/dist/forms-core/us-states.js.map +1 -0
- package/dist/forms-core/validation.d.ts +266 -0
- package/dist/forms-core/validation.d.ts.map +1 -0
- package/dist/forms-core/validation.js +850 -0
- package/dist/forms-core/validation.js.map +1 -0
- package/dist/forms.d.ts +19 -0
- package/dist/forms.d.ts.map +1 -0
- package/dist/forms.js +29 -0
- package/dist/forms.js.map +1 -0
- package/dist/gallery-types.d.ts +32 -0
- package/dist/gallery-types.d.ts.map +1 -0
- package/dist/gallery-types.js +2 -0
- package/dist/gallery-types.js.map +1 -0
- package/dist/graphql.d.ts +97 -0
- package/dist/graphql.d.ts.map +1 -0
- package/dist/graphql.js +212 -0
- package/dist/graphql.js.map +1 -0
- package/dist/index.cjs +19448 -0
- package/dist/index.d.cts +19 -0
- package/dist/index.d.mts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/marketing-schema.d.ts +216 -0
- package/dist/marketing-schema.d.ts.map +1 -0
- package/dist/marketing-schema.js +200 -0
- package/dist/marketing-schema.js.map +1 -0
- package/dist/mcp.d.ts +42 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +87 -0
- package/dist/mcp.js.map +1 -0
- package/dist/native.d.ts +45 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +43 -0
- package/dist/native.js.map +1 -0
- package/dist/next-revalidate.d.ts +43 -0
- package/dist/next-revalidate.d.ts.map +1 -0
- package/dist/next-revalidate.js +84 -0
- package/dist/next-revalidate.js.map +1 -0
- package/dist/parallel-pages.d.ts +102 -0
- package/dist/parallel-pages.d.ts.map +1 -0
- package/dist/parallel-pages.js +108 -0
- package/dist/parallel-pages.js.map +1 -0
- package/dist/payment-icons.d.ts +66 -0
- package/dist/payment-icons.d.ts.map +1 -0
- package/dist/payment-icons.js +160 -0
- package/dist/payment-icons.js.map +1 -0
- package/dist/proxy.d.ts +67 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +97 -0
- package/dist/proxy.js.map +1 -0
- package/dist/react-analytics.d.ts +39 -0
- package/dist/react-analytics.d.ts.map +1 -0
- package/dist/react-analytics.js +59 -0
- package/dist/react-analytics.js.map +1 -0
- package/dist/react-attribution.d.ts +99 -0
- package/dist/react-attribution.d.ts.map +1 -0
- package/dist/react-attribution.js +52 -0
- package/dist/react-attribution.js.map +1 -0
- package/dist/react-marketing.d.ts +181 -0
- package/dist/react-marketing.d.ts.map +1 -0
- package/dist/react-marketing.js +215 -0
- package/dist/react-marketing.js.map +1 -0
- package/dist/react.d.ts +727 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +2418 -0
- package/dist/react.js.map +1 -0
- package/dist/reserved-paths.d.ts +24 -0
- package/dist/reserved-paths.d.ts.map +1 -0
- package/dist/reserved-paths.js +60 -0
- package/dist/reserved-paths.js.map +1 -0
- package/dist/revalidate-verify.d.ts +30 -0
- package/dist/revalidate-verify.d.ts.map +1 -0
- package/dist/revalidate-verify.js +74 -0
- package/dist/revalidate-verify.js.map +1 -0
- package/dist/sdk.d.ts +678 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +801 -0
- package/dist/sdk.js.map +1 -0
- package/dist/seo.d.ts +218 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +309 -0
- package/dist/seo.js.map +1 -0
- package/dist/socials.d.ts +47 -0
- package/dist/socials.d.ts.map +1 -0
- package/dist/socials.js +172 -0
- package/dist/socials.js.map +1 -0
- package/dist/static.d.ts +14 -0
- package/dist/static.d.ts.map +1 -0
- package/dist/static.js +14 -0
- package/dist/static.js.map +1 -0
- package/dist/url-pattern.d.ts +47 -0
- package/dist/url-pattern.d.ts.map +1 -0
- package/dist/url-pattern.js +124 -0
- package/dist/url-pattern.js.map +1 -0
- package/dist/vanilla.d.ts +77 -0
- package/dist/vanilla.d.ts.map +1 -0
- package/dist/vanilla.js +903 -0
- package/dist/vanilla.js.map +1 -0
- package/openapi.json +13779 -0
- package/package.json +204 -0
- package/src/adapters-revalidate.ts +88 -0
- package/src/alpha.ts +89 -0
- package/src/analytics-core.ts +175 -0
- package/src/analytics-server.ts +99 -0
- package/src/angular/biab-form.component.ts +1443 -0
- package/src/angular/index.ts +26 -0
- package/src/attribution-logo.ts +5 -0
- package/src/auth-handler.ts +352 -0
- package/src/cli.ts +2517 -0
- package/src/client.ts +3578 -0
- package/src/collections.ts +101 -0
- package/src/contracts.ts +3390 -0
- package/src/data-model-schema.ts +4042 -0
- package/src/distribution.ts +73 -0
- package/src/element.ts +321 -0
- package/src/enums.ts +65 -0
- package/src/errors.ts +338 -0
- package/src/forms-core/autocomplete.ts +97 -0
- package/src/forms-core/business-function.ts +209 -0
- package/src/forms-core/calculation.ts +167 -0
- package/src/forms-core/controller.ts +922 -0
- package/src/forms-core/elements.ts +86 -0
- package/src/forms-core/email-domains.ts +68 -0
- package/src/forms-core/file-rules.ts +123 -0
- package/src/forms-core/formula.ts +1339 -0
- package/src/forms-core/index.ts +122 -0
- package/src/forms-core/units.ts +308 -0
- package/src/forms-core/us-states.ts +68 -0
- package/src/forms-core/validation.ts +1037 -0
- package/src/forms.ts +63 -0
- package/src/gallery-types.ts +31 -0
- package/src/graphql.ts +295 -0
- package/src/index.ts +505 -0
- package/src/marketing-schema.ts +395 -0
- package/src/mcp.ts +108 -0
- package/src/native.ts +73 -0
- package/src/next-revalidate.ts +124 -0
- package/src/parallel-pages.ts +192 -0
- package/src/payment-icons.ts +205 -0
- package/src/proxy.ts +173 -0
- package/src/qwik.tsx +1234 -0
- package/src/react-analytics.tsx +76 -0
- package/src/react-attribution.tsx +192 -0
- package/src/react-marketing.tsx +338 -0
- package/src/react.tsx +5495 -0
- package/src/reserved-paths.ts +65 -0
- package/src/revalidate-verify.ts +98 -0
- package/src/sdk.ts +1671 -0
- package/src/seo.ts +488 -0
- package/src/socials.ts +230 -0
- package/src/solid.tsx +1466 -0
- package/src/static.ts +21 -0
- package/src/styles/biab-forms.css +484 -0
- package/src/svelte/BiabForm.svelte +1069 -0
- package/src/svelte/context.ts +57 -0
- package/src/svelte/index.ts +79 -0
- package/src/url-pattern.ts +153 -0
- package/src/vanilla.ts +1290 -0
- package/src/vue/BiabForm.vue +1538 -0
- 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
|
+
};
|