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