@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
package/src/solid.tsx
ADDED
|
@@ -0,0 +1,1466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@businessdash/sdk/solid` — the `<BiabForm>` drop-in for SolidJS.
|
|
3
|
+
*
|
|
4
|
+
* Ship mode: SOURCE (.tsx). This file is published as-is and compiled by the
|
|
5
|
+
* consumer's Solid/JSX toolchain (vite-plugin-solid / babel-preset-solid). It is
|
|
6
|
+
* NOT tsc-compiled inside biab-dev, so it has zero build artifact here.
|
|
7
|
+
*
|
|
8
|
+
* It is a thin, idiomatic Solid view over the framework-agnostic headless core
|
|
9
|
+
* (`createFormController`, exported from `@businessdash/sdk/forms`). All runtime
|
|
10
|
+
* behaviour — values, errors, condition-aware visibility, multi-step nav,
|
|
11
|
+
* concurrent-reveal gating, submit lifecycle, file uploads — lives in the core.
|
|
12
|
+
* The binding only:
|
|
13
|
+
*
|
|
14
|
+
* 1. Bridges the controller's subscribe/snapshot observable into Solid
|
|
15
|
+
* reactivity with `from()` (the canonical "external store → Signal" adapter).
|
|
16
|
+
* 2. Renders the snapshot's nested field tree by `type` using the `biab-*`
|
|
17
|
+
* light-DOM class convention (no shadow root) so the consumer's own CSS
|
|
18
|
+
* cascades onto every field.
|
|
19
|
+
* 3. Applies the published `schema.settings` (animation, multi-step + progress
|
|
20
|
+
* bar/steps, concurrent reveal) and the org icon, so the drop-in reproduces
|
|
21
|
+
* the dashboard's form preview 1:1.
|
|
22
|
+
*
|
|
23
|
+
* Data flows entirely through the SDK client (auth, caching, revalidation, HMAC,
|
|
24
|
+
* retries, CRM fan-out all stay invisible). SSR / RSC callers can hand a
|
|
25
|
+
* pre-fetched `schema` instead of a `client`.
|
|
26
|
+
*
|
|
27
|
+
* @module
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { JSX } from "solid-js";
|
|
31
|
+
import {
|
|
32
|
+
type Accessor,
|
|
33
|
+
For,
|
|
34
|
+
Match,
|
|
35
|
+
Show,
|
|
36
|
+
Switch,
|
|
37
|
+
createContext,
|
|
38
|
+
createMemo,
|
|
39
|
+
createSignal,
|
|
40
|
+
createUniqueId,
|
|
41
|
+
from,
|
|
42
|
+
onCleanup,
|
|
43
|
+
onMount,
|
|
44
|
+
useContext,
|
|
45
|
+
} from "solid-js";
|
|
46
|
+
|
|
47
|
+
import type {
|
|
48
|
+
BiabClient,
|
|
49
|
+
FormFileValue,
|
|
50
|
+
FormSchema,
|
|
51
|
+
FormSubmitResult,
|
|
52
|
+
} from "./sdk.js";
|
|
53
|
+
import {
|
|
54
|
+
type CreateFormControllerOptions,
|
|
55
|
+
type FieldError,
|
|
56
|
+
type FormController,
|
|
57
|
+
type FormCoreField,
|
|
58
|
+
type FormSnapshot,
|
|
59
|
+
type FormSubmitOptions,
|
|
60
|
+
concurrentRevealCount,
|
|
61
|
+
createFormController,
|
|
62
|
+
evalCondition,
|
|
63
|
+
} from "./forms-core/index.js";
|
|
64
|
+
import {
|
|
65
|
+
type AutocompleteConfig,
|
|
66
|
+
resolveFieldAutocomplete,
|
|
67
|
+
} from "./forms-core/autocomplete.js";
|
|
68
|
+
|
|
69
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
// Client context — optional Provider-based DI so a tree of `<BiabForm>`s can
|
|
71
|
+
// share one client instead of threading `client` through every prop.
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const BiabClientContext = createContext<Accessor<BiabClient | undefined>>();
|
|
75
|
+
|
|
76
|
+
export interface BiabProviderProps {
|
|
77
|
+
/** The SDK client shared with descendant `<BiabForm>`s. */
|
|
78
|
+
client: BiabClient;
|
|
79
|
+
children?: JSX.Element;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Provide a single `BiabClient` to a subtree. Any descendant `<BiabForm>` that
|
|
84
|
+
* doesn't get its own `client` prop falls back to this one. Optional — passing
|
|
85
|
+
* `client` directly to `<BiabForm>` works without a provider.
|
|
86
|
+
*/
|
|
87
|
+
export function BiabProvider(props: BiabProviderProps): JSX.Element {
|
|
88
|
+
const value = createMemo(() => props.client);
|
|
89
|
+
return (
|
|
90
|
+
<BiabClientContext.Provider value={value}>
|
|
91
|
+
{props.children}
|
|
92
|
+
</BiabClientContext.Provider>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Read the client supplied by the nearest `<BiabProvider>` (or `undefined`). */
|
|
97
|
+
export function useBiabClient(): BiabClient | undefined {
|
|
98
|
+
const ctx = useContext(BiabClientContext);
|
|
99
|
+
return ctx?.();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
// createFormStore — bridge a FormController into a reactive Solid accessor.
|
|
104
|
+
//
|
|
105
|
+
// `from()` adopts the controller's subscribe/notify observable; we seed it with
|
|
106
|
+
// the current `snapshot()` so the accessor is valid synchronously (the core does
|
|
107
|
+
// NOT replay on subscribe). The returned accessor re-runs any tracking scope on
|
|
108
|
+
// every notify. Disposes the controller on cleanup.
|
|
109
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
export interface BiabFormStore {
|
|
112
|
+
/** Reactive snapshot accessor — read it inside JSX / memos to track changes. */
|
|
113
|
+
snapshot: Accessor<FormSnapshot>;
|
|
114
|
+
/** The underlying controller (imperative escape hatch). */
|
|
115
|
+
controller: FormController;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create a controller-backed reactive store. Kicks off `load()` on mount (so
|
|
120
|
+
* SSR-adopted schemas stay synchronous and client loads fire once), and tears
|
|
121
|
+
* the controller down on cleanup. Use this when you want the headless behaviour
|
|
122
|
+
* but your own markup; `<BiabForm>` uses it internally.
|
|
123
|
+
*/
|
|
124
|
+
export function createFormStore(
|
|
125
|
+
options: CreateFormControllerOptions,
|
|
126
|
+
): BiabFormStore {
|
|
127
|
+
const controller = createFormController(options);
|
|
128
|
+
|
|
129
|
+
// `from` wraps the subscribe contract: it calls subscribe(setter) and returns
|
|
130
|
+
// an accessor. We pre-seed with snapshot() because the core never replays on
|
|
131
|
+
// subscribe, so the very first read must not be undefined.
|
|
132
|
+
const tracked = from<FormSnapshot>((set) => {
|
|
133
|
+
set(controller.snapshot());
|
|
134
|
+
return controller.subscribe(set);
|
|
135
|
+
});
|
|
136
|
+
// `from` accessors are `T | undefined` until the first emit; the seed above
|
|
137
|
+
// makes that window empty, but narrow it for callers regardless.
|
|
138
|
+
const snapshot: Accessor<FormSnapshot> = () =>
|
|
139
|
+
tracked() ?? controller.snapshot();
|
|
140
|
+
|
|
141
|
+
onMount(() => {
|
|
142
|
+
void controller.load();
|
|
143
|
+
});
|
|
144
|
+
onCleanup(() => controller.destroy());
|
|
145
|
+
|
|
146
|
+
return { snapshot, controller };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
// CSS-hook helpers — mirror the dashboard renderer's `data-biab-*` attributes so
|
|
151
|
+
// org overrides written against the dashboard preview also target this drop-in.
|
|
152
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/** camelCase a label into a stable object-key slug (mirrors output-keys.ts). */
|
|
155
|
+
function slugifyLabel(raw: string): string {
|
|
156
|
+
const words = raw
|
|
157
|
+
.replace(/[^a-zA-Z0-9]+/g, " ")
|
|
158
|
+
.trim()
|
|
159
|
+
.split(/\s+/)
|
|
160
|
+
.filter(Boolean);
|
|
161
|
+
if (words.length === 0) return "";
|
|
162
|
+
const key = words
|
|
163
|
+
.map((w, i) =>
|
|
164
|
+
i === 0
|
|
165
|
+
? w.toLowerCase()
|
|
166
|
+
: w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(),
|
|
167
|
+
)
|
|
168
|
+
.join("");
|
|
169
|
+
return /^[0-9]/.test(key) ? `_${key}` : key;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The `data-biab-field` value: a published `key`, else an explicit `outputKey`,
|
|
174
|
+
* else a camelCase slug of the label, else the id. Lets orgs target one field.
|
|
175
|
+
*/
|
|
176
|
+
function fieldCssKey(field: FormCoreField): string {
|
|
177
|
+
const published = typeof field.key === "string" ? field.key.trim() : "";
|
|
178
|
+
if (published) return published;
|
|
179
|
+
const explicit =
|
|
180
|
+
typeof field.outputKey === "string" ? field.outputKey.trim() : "";
|
|
181
|
+
if (explicit) return explicit;
|
|
182
|
+
const label = typeof field.label === "string" ? field.label.trim() : "";
|
|
183
|
+
return (label && slugifyLabel(label)) || field.id;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const STATIC_NO_LABEL = new Set(["title", "subtitle", "separator", "richtext"]);
|
|
187
|
+
|
|
188
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
189
|
+
// FieldInput — render one value-bearing field by `type`. Solid-idiomatic: each
|
|
190
|
+
// `case` reads `props.value` reactively (the parent passes a live accessor) and
|
|
191
|
+
// writes back via `props.set`. Mirrors FormPreview's `FieldInput`.
|
|
192
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
interface FieldInputProps {
|
|
195
|
+
field: FormCoreField;
|
|
196
|
+
value: Accessor<unknown>;
|
|
197
|
+
set: (next: unknown) => void;
|
|
198
|
+
uploadFile?: (file: File, filename?: string) => Promise<FormFileValue>;
|
|
199
|
+
uploading: Accessor<boolean>;
|
|
200
|
+
/** Resolve the HTML autofill token for this field (or `undefined` to omit). */
|
|
201
|
+
autocomplete?: (field: FormCoreField) => string | undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function asString(v: unknown): string {
|
|
205
|
+
return typeof v === "string" ? v : "";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function FieldInput(props: FieldInputProps): JSX.Element {
|
|
209
|
+
const t = () => props.field.type;
|
|
210
|
+
const field = () => props.field;
|
|
211
|
+
// Resolved HTML autofill token for this field (or undefined to omit the
|
|
212
|
+
// attribute). Honors the global `<BiabForm autoComplete>` switch and per-field
|
|
213
|
+
// overrides; see forms-core/autocomplete.ts.
|
|
214
|
+
const ac = () => props.autocomplete?.(props.field);
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<Switch
|
|
218
|
+
fallback={
|
|
219
|
+
<input
|
|
220
|
+
class="biab-input"
|
|
221
|
+
type="text"
|
|
222
|
+
placeholder={field().placeholder ?? ""}
|
|
223
|
+
value={asString(props.value())}
|
|
224
|
+
onInput={(e) => props.set(e.currentTarget.value)}
|
|
225
|
+
autocomplete={ac()}
|
|
226
|
+
/>
|
|
227
|
+
}
|
|
228
|
+
>
|
|
229
|
+
<Match when={t() === "title"}>
|
|
230
|
+
<h2 class="biab-field__title">{field().label || "Untitled"}</h2>
|
|
231
|
+
</Match>
|
|
232
|
+
<Match when={t() === "subtitle"}>
|
|
233
|
+
<h3 class="biab-field__subtitle">{field().label || ""}</h3>
|
|
234
|
+
</Match>
|
|
235
|
+
<Match when={t() === "separator"}>
|
|
236
|
+
<hr class="biab-field__separator" />
|
|
237
|
+
</Match>
|
|
238
|
+
<Match when={t() === "richtext"}>
|
|
239
|
+
{/* The published content is org-authored; render it as plain text to
|
|
240
|
+
keep the binding free of an HTML-sanitizer dependency. Consumers
|
|
241
|
+
needing rich HTML can override this field via the `field` slot. */}
|
|
242
|
+
<div class="biab-field__richtext">{field().content ?? ""}</div>
|
|
243
|
+
</Match>
|
|
244
|
+
<Match when={t() === "image"}>
|
|
245
|
+
<Show when={(field().imageConfig as { src?: string })?.src}>
|
|
246
|
+
{(src) => (
|
|
247
|
+
<img
|
|
248
|
+
class="biab-field__image"
|
|
249
|
+
src={src()}
|
|
250
|
+
alt={(field().imageConfig as { alt?: string })?.alt ?? ""}
|
|
251
|
+
/>
|
|
252
|
+
)}
|
|
253
|
+
</Show>
|
|
254
|
+
</Match>
|
|
255
|
+
|
|
256
|
+
<Match when={t() === "textarea"}>
|
|
257
|
+
<textarea
|
|
258
|
+
class="biab-textarea"
|
|
259
|
+
placeholder={field().placeholder ?? ""}
|
|
260
|
+
rows={(field().textareaConfig as { rows?: number })?.rows ?? 4}
|
|
261
|
+
value={asString(props.value())}
|
|
262
|
+
onInput={(e) => props.set(e.currentTarget.value)}
|
|
263
|
+
autocomplete={ac()}
|
|
264
|
+
/>
|
|
265
|
+
</Match>
|
|
266
|
+
|
|
267
|
+
<Match when={t() === "select"}>
|
|
268
|
+
<select
|
|
269
|
+
class="biab-input biab-select"
|
|
270
|
+
value={asString(props.value())}
|
|
271
|
+
onChange={(e) => props.set(e.currentTarget.value)}
|
|
272
|
+
>
|
|
273
|
+
<option value="" disabled>
|
|
274
|
+
{field().placeholder || "Select..."}
|
|
275
|
+
</option>
|
|
276
|
+
<For each={field().options ?? []}>
|
|
277
|
+
{(opt) => <option value={opt}>{opt}</option>}
|
|
278
|
+
</For>
|
|
279
|
+
</select>
|
|
280
|
+
</Match>
|
|
281
|
+
|
|
282
|
+
<Match when={t() === "radio"}>
|
|
283
|
+
<div class="biab-radio-group">
|
|
284
|
+
<For each={field().options ?? []}>
|
|
285
|
+
{(opt) => {
|
|
286
|
+
const id = `${field().id}-${opt}`;
|
|
287
|
+
return (
|
|
288
|
+
<label class="biab-radio" for={id}>
|
|
289
|
+
<input
|
|
290
|
+
type="radio"
|
|
291
|
+
id={id}
|
|
292
|
+
name={field().id}
|
|
293
|
+
value={opt}
|
|
294
|
+
checked={asString(props.value()) === opt}
|
|
295
|
+
onChange={() => props.set(opt)}
|
|
296
|
+
/>
|
|
297
|
+
<span class="biab-radio__label">{opt}</span>
|
|
298
|
+
</label>
|
|
299
|
+
);
|
|
300
|
+
}}
|
|
301
|
+
</For>
|
|
302
|
+
</div>
|
|
303
|
+
</Match>
|
|
304
|
+
|
|
305
|
+
<Match when={t() === "checkbox"}>
|
|
306
|
+
<div class="biab-checkbox-group">
|
|
307
|
+
<For each={field().options ?? []}>
|
|
308
|
+
{(opt) => {
|
|
309
|
+
const arr = () =>
|
|
310
|
+
Array.isArray(props.value())
|
|
311
|
+
? (props.value() as string[])
|
|
312
|
+
: [];
|
|
313
|
+
return (
|
|
314
|
+
<label class="biab-checkbox">
|
|
315
|
+
<input
|
|
316
|
+
type="checkbox"
|
|
317
|
+
value={opt}
|
|
318
|
+
checked={arr().includes(opt)}
|
|
319
|
+
onChange={(e) => {
|
|
320
|
+
const cur = arr();
|
|
321
|
+
props.set(
|
|
322
|
+
e.currentTarget.checked
|
|
323
|
+
? [...cur, opt]
|
|
324
|
+
: cur.filter((x) => x !== opt),
|
|
325
|
+
);
|
|
326
|
+
}}
|
|
327
|
+
/>
|
|
328
|
+
<span class="biab-checkbox__label">{opt}</span>
|
|
329
|
+
</label>
|
|
330
|
+
);
|
|
331
|
+
}}
|
|
332
|
+
</For>
|
|
333
|
+
</div>
|
|
334
|
+
</Match>
|
|
335
|
+
|
|
336
|
+
<Match when={t() === "boolean"}>
|
|
337
|
+
<label class="biab-switch">
|
|
338
|
+
<input
|
|
339
|
+
type="checkbox"
|
|
340
|
+
checked={props.value() === true}
|
|
341
|
+
onChange={(e) => props.set(e.currentTarget.checked)}
|
|
342
|
+
/>
|
|
343
|
+
<span class="biab-switch__label">
|
|
344
|
+
{field().placeholder || "Yes / No"}
|
|
345
|
+
</span>
|
|
346
|
+
</label>
|
|
347
|
+
</Match>
|
|
348
|
+
|
|
349
|
+
<Match when={t() === "date"}>
|
|
350
|
+
<input
|
|
351
|
+
class="biab-input"
|
|
352
|
+
type="date"
|
|
353
|
+
value={asString(props.value())}
|
|
354
|
+
onInput={(e) => props.set(e.currentTarget.value)}
|
|
355
|
+
/>
|
|
356
|
+
</Match>
|
|
357
|
+
|
|
358
|
+
<Match when={t() === "email"}>
|
|
359
|
+
<input
|
|
360
|
+
class="biab-input"
|
|
361
|
+
type="email"
|
|
362
|
+
placeholder={field().placeholder || "you@example.com"}
|
|
363
|
+
value={asString(props.value())}
|
|
364
|
+
onInput={(e) => props.set(e.currentTarget.value)}
|
|
365
|
+
autocomplete={ac()}
|
|
366
|
+
/>
|
|
367
|
+
</Match>
|
|
368
|
+
|
|
369
|
+
<Match when={t() === "phone"}>
|
|
370
|
+
<div class="biab-phone">
|
|
371
|
+
<Show when={(field().phoneConfig as { showFlags?: boolean })?.showFlags}>
|
|
372
|
+
<span class="biab-phone__flag" aria-hidden="true">
|
|
373
|
+
🇺🇸
|
|
374
|
+
</span>
|
|
375
|
+
</Show>
|
|
376
|
+
<input
|
|
377
|
+
class="biab-input"
|
|
378
|
+
type="tel"
|
|
379
|
+
placeholder={
|
|
380
|
+
field().placeholder ||
|
|
381
|
+
((field().phoneConfig as { international?: boolean })?.international
|
|
382
|
+
? "+1 (555) 000-0000"
|
|
383
|
+
: "(555) 000-0000")
|
|
384
|
+
}
|
|
385
|
+
value={asString(props.value())}
|
|
386
|
+
onInput={(e) => props.set(e.currentTarget.value)}
|
|
387
|
+
autocomplete={ac()}
|
|
388
|
+
/>
|
|
389
|
+
</div>
|
|
390
|
+
</Match>
|
|
391
|
+
|
|
392
|
+
<Match when={t() === "number"}>
|
|
393
|
+
<input
|
|
394
|
+
class="biab-input"
|
|
395
|
+
type="number"
|
|
396
|
+
placeholder={field().placeholder ?? ""}
|
|
397
|
+
value={
|
|
398
|
+
typeof props.value() === "number"
|
|
399
|
+
? (props.value() as number)
|
|
400
|
+
: asString(props.value())
|
|
401
|
+
}
|
|
402
|
+
onInput={(e) =>
|
|
403
|
+
props.set(
|
|
404
|
+
e.currentTarget.value === ""
|
|
405
|
+
? ""
|
|
406
|
+
: Number(e.currentTarget.value),
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
/>
|
|
410
|
+
</Match>
|
|
411
|
+
|
|
412
|
+
<Match when={t() === "color_picker"}>
|
|
413
|
+
<div class="biab-color-group">
|
|
414
|
+
<For each={field().options ?? []}>
|
|
415
|
+
{(color) => (
|
|
416
|
+
<button
|
|
417
|
+
type="button"
|
|
418
|
+
class="biab-color-swatch"
|
|
419
|
+
classList={{
|
|
420
|
+
"biab-color-swatch--selected":
|
|
421
|
+
asString(props.value()) === color,
|
|
422
|
+
}}
|
|
423
|
+
style={{ "background-color": color }}
|
|
424
|
+
title={color}
|
|
425
|
+
onClick={() => props.set(color)}
|
|
426
|
+
/>
|
|
427
|
+
)}
|
|
428
|
+
</For>
|
|
429
|
+
</div>
|
|
430
|
+
</Match>
|
|
431
|
+
|
|
432
|
+
<Match when={t() === "slider"}>
|
|
433
|
+
{(() => {
|
|
434
|
+
const cfg = () =>
|
|
435
|
+
(field().sliderConfig ?? {}) as {
|
|
436
|
+
min?: number;
|
|
437
|
+
max?: number;
|
|
438
|
+
step?: number;
|
|
439
|
+
unit?: string;
|
|
440
|
+
};
|
|
441
|
+
const min = () => cfg().min ?? 0;
|
|
442
|
+
const max = () => cfg().max ?? 100;
|
|
443
|
+
const cur = () =>
|
|
444
|
+
typeof props.value() === "number"
|
|
445
|
+
? (props.value() as number)
|
|
446
|
+
: min();
|
|
447
|
+
const fmt = (n: number) =>
|
|
448
|
+
cfg().unit === "currency"
|
|
449
|
+
? `$${n}`
|
|
450
|
+
: cfg().unit === "percent"
|
|
451
|
+
? `${n}%`
|
|
452
|
+
: `${n}`;
|
|
453
|
+
return (
|
|
454
|
+
<div class="biab-slider">
|
|
455
|
+
<div class="biab-slider__scale">
|
|
456
|
+
<span>{fmt(min())}</span>
|
|
457
|
+
<span class="biab-slider__value">{fmt(cur())}</span>
|
|
458
|
+
<span>{fmt(max())}</span>
|
|
459
|
+
</div>
|
|
460
|
+
<input
|
|
461
|
+
type="range"
|
|
462
|
+
min={min()}
|
|
463
|
+
max={max()}
|
|
464
|
+
step={cfg().step ?? 1}
|
|
465
|
+
value={cur()}
|
|
466
|
+
onInput={(e) => props.set(Number(e.currentTarget.value))}
|
|
467
|
+
/>
|
|
468
|
+
</div>
|
|
469
|
+
);
|
|
470
|
+
})()}
|
|
471
|
+
</Match>
|
|
472
|
+
|
|
473
|
+
<Match when={t() === "stepper"}>
|
|
474
|
+
{(() => {
|
|
475
|
+
const cur = () =>
|
|
476
|
+
typeof props.value() === "number" ? (props.value() as number) : 0;
|
|
477
|
+
return (
|
|
478
|
+
<div class="biab-stepper">
|
|
479
|
+
<button
|
|
480
|
+
type="button"
|
|
481
|
+
class="biab-stepper__btn"
|
|
482
|
+
onClick={() => props.set(cur() - 1)}
|
|
483
|
+
>
|
|
484
|
+
−
|
|
485
|
+
</button>
|
|
486
|
+
<span class="biab-stepper__value">{cur()}</span>
|
|
487
|
+
<button
|
|
488
|
+
type="button"
|
|
489
|
+
class="biab-stepper__btn"
|
|
490
|
+
onClick={() => props.set(cur() + 1)}
|
|
491
|
+
>
|
|
492
|
+
+
|
|
493
|
+
</button>
|
|
494
|
+
</div>
|
|
495
|
+
);
|
|
496
|
+
})()}
|
|
497
|
+
</Match>
|
|
498
|
+
|
|
499
|
+
<Match when={t() === "star_rating"}>
|
|
500
|
+
{(() => {
|
|
501
|
+
const maxStars = () =>
|
|
502
|
+
(field().starRatingConfig as { maxStars?: number })?.maxStars ?? 5;
|
|
503
|
+
const cur = () =>
|
|
504
|
+
typeof props.value() === "number" ? (props.value() as number) : 0;
|
|
505
|
+
return (
|
|
506
|
+
<div class="biab-stars">
|
|
507
|
+
<For each={Array.from({ length: maxStars() })}>
|
|
508
|
+
{(_, i) => (
|
|
509
|
+
<button
|
|
510
|
+
type="button"
|
|
511
|
+
class="biab-stars__star"
|
|
512
|
+
classList={{ "biab-stars__star--on": i() < cur() }}
|
|
513
|
+
aria-label={`${i() + 1} star`}
|
|
514
|
+
onClick={() => props.set(i() + 1)}
|
|
515
|
+
>
|
|
516
|
+
★
|
|
517
|
+
</button>
|
|
518
|
+
)}
|
|
519
|
+
</For>
|
|
520
|
+
</div>
|
|
521
|
+
);
|
|
522
|
+
})()}
|
|
523
|
+
</Match>
|
|
524
|
+
|
|
525
|
+
<Match when={t() === "happiness_rating"}>
|
|
526
|
+
{(() => {
|
|
527
|
+
const cur = () =>
|
|
528
|
+
typeof props.value() === "number" ? (props.value() as number) : -1;
|
|
529
|
+
const emojis = ["😞", "😕", "😐", "🙂", "😄"];
|
|
530
|
+
return (
|
|
531
|
+
<div class="biab-happiness">
|
|
532
|
+
<For each={emojis}>
|
|
533
|
+
{(emoji, i) => (
|
|
534
|
+
<button
|
|
535
|
+
type="button"
|
|
536
|
+
class="biab-happiness__face"
|
|
537
|
+
classList={{ "biab-happiness__face--on": i() === cur() }}
|
|
538
|
+
onClick={() => props.set(i())}
|
|
539
|
+
>
|
|
540
|
+
{emoji}
|
|
541
|
+
</button>
|
|
542
|
+
)}
|
|
543
|
+
</For>
|
|
544
|
+
</div>
|
|
545
|
+
);
|
|
546
|
+
})()}
|
|
547
|
+
</Match>
|
|
548
|
+
|
|
549
|
+
<Match when={t() === "time_picker"}>
|
|
550
|
+
{(() => {
|
|
551
|
+
const cfg = () =>
|
|
552
|
+
(field().timeConfig ?? {}) as {
|
|
553
|
+
mode?: "simple" | "time_block" | "appointment";
|
|
554
|
+
format?: "12h" | "24h";
|
|
555
|
+
stepMinutes?: number;
|
|
556
|
+
};
|
|
557
|
+
const mode = () => cfg().mode ?? "simple";
|
|
558
|
+
return (
|
|
559
|
+
<Switch>
|
|
560
|
+
<Match when={mode() === "simple"}>
|
|
561
|
+
{(() => {
|
|
562
|
+
const step = () => Math.max(cfg().stepMinutes ?? 30, 1);
|
|
563
|
+
const opts = createMemo(() => {
|
|
564
|
+
const out: string[] = [];
|
|
565
|
+
for (let h = 0; h < 24; h++) {
|
|
566
|
+
for (let m = 0; m < 60; m += step()) {
|
|
567
|
+
out.push(
|
|
568
|
+
`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return out;
|
|
573
|
+
});
|
|
574
|
+
const label = (tt: string) => {
|
|
575
|
+
if (cfg().format === "24h") return tt;
|
|
576
|
+
const [hStr, mStr = "00"] = tt.split(":");
|
|
577
|
+
const h = Number(hStr);
|
|
578
|
+
const ampm = h < 12 ? "AM" : "PM";
|
|
579
|
+
const h12 = h % 12 === 0 ? 12 : h % 12;
|
|
580
|
+
return `${h12}:${mStr} ${ampm}`;
|
|
581
|
+
};
|
|
582
|
+
return (
|
|
583
|
+
<select
|
|
584
|
+
class="biab-input biab-select"
|
|
585
|
+
value={asString(props.value())}
|
|
586
|
+
onChange={(e) => props.set(e.currentTarget.value)}
|
|
587
|
+
>
|
|
588
|
+
<option value="" disabled>
|
|
589
|
+
{field().placeholder || "Select time"}
|
|
590
|
+
</option>
|
|
591
|
+
<For each={opts()}>
|
|
592
|
+
{(o) => <option value={o}>{label(o)}</option>}
|
|
593
|
+
</For>
|
|
594
|
+
</select>
|
|
595
|
+
);
|
|
596
|
+
})()}
|
|
597
|
+
</Match>
|
|
598
|
+
<Match when={mode() === "time_block"}>
|
|
599
|
+
{(() => {
|
|
600
|
+
const v = () =>
|
|
601
|
+
(props.value() &&
|
|
602
|
+
typeof props.value() === "object"
|
|
603
|
+
? (props.value() as { start?: string; end?: string })
|
|
604
|
+
: { start: "", end: "" });
|
|
605
|
+
return (
|
|
606
|
+
<div class="biab-time-block">
|
|
607
|
+
<label class="biab-subfield">
|
|
608
|
+
<span class="biab-subfield__label">Start</span>
|
|
609
|
+
<input
|
|
610
|
+
class="biab-input"
|
|
611
|
+
type="time"
|
|
612
|
+
value={v().start ?? ""}
|
|
613
|
+
onInput={(e) =>
|
|
614
|
+
props.set({ ...v(), start: e.currentTarget.value })
|
|
615
|
+
}
|
|
616
|
+
/>
|
|
617
|
+
</label>
|
|
618
|
+
<label class="biab-subfield">
|
|
619
|
+
<span class="biab-subfield__label">End</span>
|
|
620
|
+
<input
|
|
621
|
+
class="biab-input"
|
|
622
|
+
type="time"
|
|
623
|
+
value={v().end ?? ""}
|
|
624
|
+
onInput={(e) =>
|
|
625
|
+
props.set({ ...v(), end: e.currentTarget.value })
|
|
626
|
+
}
|
|
627
|
+
/>
|
|
628
|
+
</label>
|
|
629
|
+
</div>
|
|
630
|
+
);
|
|
631
|
+
})()}
|
|
632
|
+
</Match>
|
|
633
|
+
<Match when={mode() === "appointment"}>
|
|
634
|
+
{(() => {
|
|
635
|
+
const v = () =>
|
|
636
|
+
(props.value() &&
|
|
637
|
+
typeof props.value() === "object"
|
|
638
|
+
? (props.value() as { date?: string; time?: string })
|
|
639
|
+
: { date: "", time: "" });
|
|
640
|
+
return (
|
|
641
|
+
<div class="biab-appointment">
|
|
642
|
+
<label class="biab-subfield">
|
|
643
|
+
<span class="biab-subfield__label">Date</span>
|
|
644
|
+
<input
|
|
645
|
+
class="biab-input"
|
|
646
|
+
type="date"
|
|
647
|
+
value={v().date ?? ""}
|
|
648
|
+
onInput={(e) =>
|
|
649
|
+
props.set({ ...v(), date: e.currentTarget.value })
|
|
650
|
+
}
|
|
651
|
+
/>
|
|
652
|
+
</label>
|
|
653
|
+
<label class="biab-subfield">
|
|
654
|
+
<span class="biab-subfield__label">Time</span>
|
|
655
|
+
<input
|
|
656
|
+
class="biab-input"
|
|
657
|
+
type="time"
|
|
658
|
+
value={v().time ?? ""}
|
|
659
|
+
onInput={(e) =>
|
|
660
|
+
props.set({ ...v(), time: e.currentTarget.value })
|
|
661
|
+
}
|
|
662
|
+
/>
|
|
663
|
+
</label>
|
|
664
|
+
</div>
|
|
665
|
+
);
|
|
666
|
+
})()}
|
|
667
|
+
</Match>
|
|
668
|
+
</Switch>
|
|
669
|
+
);
|
|
670
|
+
})()}
|
|
671
|
+
</Match>
|
|
672
|
+
|
|
673
|
+
<Match when={t() === "date_range"}>
|
|
674
|
+
{(() => {
|
|
675
|
+
const v = () =>
|
|
676
|
+
(props.value() && typeof props.value() === "object"
|
|
677
|
+
? (props.value() as { start?: string; end?: string })
|
|
678
|
+
: { start: "", end: "" });
|
|
679
|
+
return (
|
|
680
|
+
<div class="biab-date-range">
|
|
681
|
+
<label class="biab-subfield">
|
|
682
|
+
<span class="biab-subfield__label">Start</span>
|
|
683
|
+
<input
|
|
684
|
+
class="biab-input"
|
|
685
|
+
type="date"
|
|
686
|
+
value={v().start ?? ""}
|
|
687
|
+
onInput={(e) =>
|
|
688
|
+
props.set({ ...v(), start: e.currentTarget.value })
|
|
689
|
+
}
|
|
690
|
+
/>
|
|
691
|
+
</label>
|
|
692
|
+
<label class="biab-subfield">
|
|
693
|
+
<span class="biab-subfield__label">End</span>
|
|
694
|
+
<input
|
|
695
|
+
class="biab-input"
|
|
696
|
+
type="date"
|
|
697
|
+
value={v().end ?? ""}
|
|
698
|
+
onInput={(e) =>
|
|
699
|
+
props.set({ ...v(), end: e.currentTarget.value })
|
|
700
|
+
}
|
|
701
|
+
/>
|
|
702
|
+
</label>
|
|
703
|
+
</div>
|
|
704
|
+
);
|
|
705
|
+
})()}
|
|
706
|
+
</Match>
|
|
707
|
+
|
|
708
|
+
<Match when={t() === "address"}>
|
|
709
|
+
{(() => {
|
|
710
|
+
const v = () =>
|
|
711
|
+
(props.value() && typeof props.value() === "object"
|
|
712
|
+
? (props.value() as Record<string, string>)
|
|
713
|
+
: {}) as Record<string, string>;
|
|
714
|
+
// Fixed per-subfield autofill tokens, suppressed to "off" when the
|
|
715
|
+
// field's resolved token is "off" (global switch or per-field override).
|
|
716
|
+
const acOff = () => ac() === "off";
|
|
717
|
+
const subAc = (token: string) => (acOff() ? "off" : token);
|
|
718
|
+
const part = (k: string, label: string, token: string) => (
|
|
719
|
+
<label class="biab-subfield">
|
|
720
|
+
<span class="biab-subfield__label">{label}</span>
|
|
721
|
+
<input
|
|
722
|
+
class="biab-input"
|
|
723
|
+
value={v()[k] ?? ""}
|
|
724
|
+
onInput={(e) =>
|
|
725
|
+
props.set({ ...v(), [k]: e.currentTarget.value })
|
|
726
|
+
}
|
|
727
|
+
autocomplete={subAc(token)}
|
|
728
|
+
/>
|
|
729
|
+
</label>
|
|
730
|
+
);
|
|
731
|
+
return (
|
|
732
|
+
<div class="biab-address">
|
|
733
|
+
{part("line1", "Address Line 1", "street-address")}
|
|
734
|
+
{part("line2", "Address Line 2", "address-line2")}
|
|
735
|
+
{part("city", "City", "address-level2")}
|
|
736
|
+
{part("state", "State", "address-level1")}
|
|
737
|
+
{part("zip", "Zip", "postal-code")}
|
|
738
|
+
</div>
|
|
739
|
+
);
|
|
740
|
+
})()}
|
|
741
|
+
</Match>
|
|
742
|
+
|
|
743
|
+
<Match when={t() === "file_upload"}>
|
|
744
|
+
{(() => {
|
|
745
|
+
const files = () =>
|
|
746
|
+
Array.isArray(props.value())
|
|
747
|
+
? (props.value() as FormFileValue[])
|
|
748
|
+
: [];
|
|
749
|
+
const cfg = () =>
|
|
750
|
+
(field().fileConfig ?? {}) as {
|
|
751
|
+
allowedTypes?: string[];
|
|
752
|
+
maxFiles?: number;
|
|
753
|
+
maxSize?: number;
|
|
754
|
+
};
|
|
755
|
+
const maxFiles = () => cfg().maxFiles ?? 1;
|
|
756
|
+
const remaining = () => Math.max(maxFiles() - files().length, 0);
|
|
757
|
+
const accept = () =>
|
|
758
|
+
cfg().allowedTypes && cfg().allowedTypes.length > 0
|
|
759
|
+
? cfg().allowedTypes.join(",")
|
|
760
|
+
: undefined;
|
|
761
|
+
let inputRef: HTMLInputElement | undefined;
|
|
762
|
+
const [localError, setLocalError] = createSignal<string | null>(null);
|
|
763
|
+
|
|
764
|
+
const pick = async (list: FileList | null) => {
|
|
765
|
+
if (!list || list.length === 0) return;
|
|
766
|
+
if (!props.uploadFile) return;
|
|
767
|
+
setLocalError(null);
|
|
768
|
+
const allowed = cfg().allowedTypes;
|
|
769
|
+
const maxSizeMb = cfg().maxSize;
|
|
770
|
+
const chosen = Array.from(list).slice(0, remaining());
|
|
771
|
+
if (allowed && allowed.length > 0) {
|
|
772
|
+
const bad = chosen.find((f) => !allowed.includes(f.type));
|
|
773
|
+
if (bad) {
|
|
774
|
+
setLocalError(`File type not allowed: ${bad.type || bad.name}`);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if (typeof maxSizeMb === "number" && maxSizeMb > 0) {
|
|
779
|
+
const tooBig = chosen.find(
|
|
780
|
+
(f) => f.size > maxSizeMb * 1024 * 1024,
|
|
781
|
+
);
|
|
782
|
+
if (tooBig) {
|
|
783
|
+
setLocalError(
|
|
784
|
+
`"${tooBig.name}" exceeds ${maxSizeMb} MB limit.`,
|
|
785
|
+
);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
for (const f of chosen) {
|
|
791
|
+
// The controller appends each uploaded record to the field
|
|
792
|
+
// value, so we don't merge here — just trigger the upload.
|
|
793
|
+
await props.uploadFile(f, f.name);
|
|
794
|
+
}
|
|
795
|
+
} catch (err) {
|
|
796
|
+
setLocalError(
|
|
797
|
+
err instanceof Error ? err.message : "Upload failed.",
|
|
798
|
+
);
|
|
799
|
+
} finally {
|
|
800
|
+
if (inputRef) inputRef.value = "";
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
return (
|
|
805
|
+
<div class="biab-file">
|
|
806
|
+
<input
|
|
807
|
+
ref={inputRef}
|
|
808
|
+
type="file"
|
|
809
|
+
class="biab-file__input"
|
|
810
|
+
multiple={maxFiles() > 1}
|
|
811
|
+
accept={accept()}
|
|
812
|
+
style={{ display: "none" }}
|
|
813
|
+
onChange={(e) => void pick(e.currentTarget.files)}
|
|
814
|
+
/>
|
|
815
|
+
<button
|
|
816
|
+
type="button"
|
|
817
|
+
class="biab-file__trigger"
|
|
818
|
+
disabled={props.uploading() || remaining() === 0 || !props.uploadFile}
|
|
819
|
+
onClick={() => inputRef?.click()}
|
|
820
|
+
>
|
|
821
|
+
<Show
|
|
822
|
+
when={!props.uploading()}
|
|
823
|
+
fallback={<span>Uploading…</span>}
|
|
824
|
+
>
|
|
825
|
+
<Show
|
|
826
|
+
when={remaining() > 0}
|
|
827
|
+
fallback={
|
|
828
|
+
<span>
|
|
829
|
+
Maximum {maxFiles()} file{maxFiles() === 1 ? "" : "s"}{" "}
|
|
830
|
+
reached
|
|
831
|
+
</span>
|
|
832
|
+
}
|
|
833
|
+
>
|
|
834
|
+
<span>Click to upload</span>
|
|
835
|
+
</Show>
|
|
836
|
+
</Show>
|
|
837
|
+
</button>
|
|
838
|
+
<Show when={localError()}>
|
|
839
|
+
<p class="biab-file__error">{localError()}</p>
|
|
840
|
+
</Show>
|
|
841
|
+
<Show when={files().length > 0}>
|
|
842
|
+
<ul class="biab-file__list">
|
|
843
|
+
<For each={files()}>
|
|
844
|
+
{(f, i) => (
|
|
845
|
+
<li class="biab-file__item">
|
|
846
|
+
<span class="biab-file__name">{f.name}</span>
|
|
847
|
+
<span class="biab-file__size">
|
|
848
|
+
{Math.round(f.size / 1024)} KB
|
|
849
|
+
</span>
|
|
850
|
+
<button
|
|
851
|
+
type="button"
|
|
852
|
+
class="biab-file__remove"
|
|
853
|
+
aria-label="Remove file"
|
|
854
|
+
onClick={() =>
|
|
855
|
+
props.set(
|
|
856
|
+
files().filter((_, idx) => idx !== i()),
|
|
857
|
+
)
|
|
858
|
+
}
|
|
859
|
+
>
|
|
860
|
+
×
|
|
861
|
+
</button>
|
|
862
|
+
</li>
|
|
863
|
+
)}
|
|
864
|
+
</For>
|
|
865
|
+
</ul>
|
|
866
|
+
</Show>
|
|
867
|
+
</div>
|
|
868
|
+
);
|
|
869
|
+
})()}
|
|
870
|
+
</Match>
|
|
871
|
+
</Switch>
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
876
|
+
// FieldRow — a value-bearing field plus its label / required marker / error /
|
|
877
|
+
// help text. Mirrors FormPreview's `FieldRow` + its `data-biab-*` hooks.
|
|
878
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
879
|
+
|
|
880
|
+
interface RenderCtx {
|
|
881
|
+
snapshot: Accessor<FormSnapshot>;
|
|
882
|
+
setValue: (id: string, value: unknown) => void;
|
|
883
|
+
upload: (fieldId: string, file: File, filename?: string) => Promise<FormFileValue>;
|
|
884
|
+
/** Optional consumer render override, per field. */
|
|
885
|
+
fieldSlot?: (args: FieldSlotArgs) => JSX.Element;
|
|
886
|
+
/** Resolve the HTML autofill token for a field (or `undefined` to omit). */
|
|
887
|
+
autocomplete: (field: FormCoreField) => string | undefined;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
export interface FieldSlotArgs {
|
|
891
|
+
field: FormCoreField;
|
|
892
|
+
value: unknown;
|
|
893
|
+
error: FieldError | undefined;
|
|
894
|
+
setValue: (value: unknown) => void;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function FieldRow(props: { field: FormCoreField; ctx: RenderCtx }): JSX.Element {
|
|
898
|
+
const field = () => props.field;
|
|
899
|
+
const value = () => props.ctx.snapshot().values[field().id];
|
|
900
|
+
const error = () => props.ctx.snapshot().errors[field().id];
|
|
901
|
+
const showLabel = () => !STATIC_NO_LABEL.has(field().type);
|
|
902
|
+
|
|
903
|
+
return (
|
|
904
|
+
<Show
|
|
905
|
+
when={!props.ctx.fieldSlot}
|
|
906
|
+
fallback={props.ctx.fieldSlot?.({
|
|
907
|
+
field: field(),
|
|
908
|
+
value: value(),
|
|
909
|
+
error: error(),
|
|
910
|
+
setValue: (v) => props.ctx.setValue(field().id, v),
|
|
911
|
+
})}
|
|
912
|
+
>
|
|
913
|
+
<div
|
|
914
|
+
data-field-id={field().id}
|
|
915
|
+
data-biab-field={fieldCssKey(field())}
|
|
916
|
+
data-biab-type={field().type}
|
|
917
|
+
class="biab-field"
|
|
918
|
+
classList={{
|
|
919
|
+
[`biab-field--${field().type}`]: true,
|
|
920
|
+
"biab-field--required": field().required === true,
|
|
921
|
+
"biab-field--error": Boolean(error()),
|
|
922
|
+
}}
|
|
923
|
+
>
|
|
924
|
+
<Show when={showLabel() && (field().label || field().required)}>
|
|
925
|
+
<label class="biab-field__label">
|
|
926
|
+
{field().label || ""}
|
|
927
|
+
<Show when={field().required}>
|
|
928
|
+
<span class="biab-field__required"> *</span>
|
|
929
|
+
</Show>
|
|
930
|
+
</label>
|
|
931
|
+
</Show>
|
|
932
|
+
<FieldInput
|
|
933
|
+
field={field()}
|
|
934
|
+
value={value}
|
|
935
|
+
set={(v) => props.ctx.setValue(field().id, v)}
|
|
936
|
+
uploadFile={(file, filename) =>
|
|
937
|
+
props.ctx.upload(field().id, file, filename)
|
|
938
|
+
}
|
|
939
|
+
uploading={() => props.ctx.snapshot().uploading}
|
|
940
|
+
autocomplete={props.ctx.autocomplete}
|
|
941
|
+
/>
|
|
942
|
+
<Show when={error()}>
|
|
943
|
+
{(e) => <p class="biab-field__error">{e().message}</p>}
|
|
944
|
+
</Show>
|
|
945
|
+
<Show when={field().helpText}>
|
|
946
|
+
<p class="biab-field__help">{field().helpText}</p>
|
|
947
|
+
</Show>
|
|
948
|
+
</div>
|
|
949
|
+
</Show>
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
954
|
+
// RenderTree — the recursive layout walk: input_group (+ concurrent reveal),
|
|
955
|
+
// condition_block, or_condition, flex_row, leaf fields. Mirrors FormPreview's
|
|
956
|
+
// `RenderTree` and uses the SAME `biab-*` hooks. `flat` drops the group border
|
|
957
|
+
// in consecutive mode (each step IS already a group).
|
|
958
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
959
|
+
|
|
960
|
+
function RenderTree(props: {
|
|
961
|
+
fields: FormCoreField[];
|
|
962
|
+
ctx: RenderCtx;
|
|
963
|
+
flat?: boolean;
|
|
964
|
+
}): JSX.Element {
|
|
965
|
+
return (
|
|
966
|
+
<div class="biab-tree">
|
|
967
|
+
<For each={props.fields}>
|
|
968
|
+
{(node) => (
|
|
969
|
+
<Switch fallback={<FieldRow field={node} ctx={props.ctx} />}>
|
|
970
|
+
<Match when={node.type === "input_group"}>
|
|
971
|
+
{(() => {
|
|
972
|
+
const children = () => node.children ?? [];
|
|
973
|
+
const concurrent = () => node.concurrentReveal === true;
|
|
974
|
+
const revealCount = () =>
|
|
975
|
+
concurrent()
|
|
976
|
+
? concurrentRevealCount(
|
|
977
|
+
children(),
|
|
978
|
+
props.ctx.snapshot().values,
|
|
979
|
+
)
|
|
980
|
+
: children().length;
|
|
981
|
+
return (
|
|
982
|
+
<div
|
|
983
|
+
data-biab-group={fieldCssKey(node)}
|
|
984
|
+
class="biab-group"
|
|
985
|
+
classList={{ "biab-group--bordered": !props.flat }}
|
|
986
|
+
>
|
|
987
|
+
<Show
|
|
988
|
+
when={node.label && node.showGroupLabel === true}
|
|
989
|
+
>
|
|
990
|
+
<div class="biab-group__label">{node.label}</div>
|
|
991
|
+
</Show>
|
|
992
|
+
<Show
|
|
993
|
+
when={concurrent()}
|
|
994
|
+
fallback={
|
|
995
|
+
<RenderTree fields={children()} ctx={props.ctx} />
|
|
996
|
+
}
|
|
997
|
+
>
|
|
998
|
+
<For each={children().slice(0, revealCount())}>
|
|
999
|
+
{(child) => (
|
|
1000
|
+
<div class="biab-group__reveal">
|
|
1001
|
+
<RenderTree
|
|
1002
|
+
fields={[child]}
|
|
1003
|
+
ctx={props.ctx}
|
|
1004
|
+
/>
|
|
1005
|
+
</div>
|
|
1006
|
+
)}
|
|
1007
|
+
</For>
|
|
1008
|
+
</Show>
|
|
1009
|
+
</div>
|
|
1010
|
+
);
|
|
1011
|
+
})()}
|
|
1012
|
+
</Match>
|
|
1013
|
+
|
|
1014
|
+
<Match when={node.type === "condition_block"}>
|
|
1015
|
+
<Show
|
|
1016
|
+
when={evalCondition(
|
|
1017
|
+
node.conditionRule,
|
|
1018
|
+
props.ctx.snapshot().values,
|
|
1019
|
+
)}
|
|
1020
|
+
>
|
|
1021
|
+
<RenderTree fields={node.children ?? []} ctx={props.ctx} />
|
|
1022
|
+
</Show>
|
|
1023
|
+
</Match>
|
|
1024
|
+
|
|
1025
|
+
<Match when={node.type === "or_condition"}>
|
|
1026
|
+
{(() => {
|
|
1027
|
+
const left = () => node.children?.[0];
|
|
1028
|
+
const right = () => node.children?.[1];
|
|
1029
|
+
const errored = () =>
|
|
1030
|
+
Boolean(props.ctx.snapshot().errors[node.id]);
|
|
1031
|
+
const cfg = () =>
|
|
1032
|
+
(node.orConditionConfig ?? {}) as {
|
|
1033
|
+
leftLabel?: string;
|
|
1034
|
+
rightLabel?: string;
|
|
1035
|
+
};
|
|
1036
|
+
return (
|
|
1037
|
+
<div
|
|
1038
|
+
class="biab-or"
|
|
1039
|
+
classList={{ "biab-or--error": errored() }}
|
|
1040
|
+
>
|
|
1041
|
+
<Show when={node.label}>
|
|
1042
|
+
<div class="biab-or__label">
|
|
1043
|
+
{node.label}
|
|
1044
|
+
<span class="biab-or__hint">Fill at least one side</span>
|
|
1045
|
+
</div>
|
|
1046
|
+
</Show>
|
|
1047
|
+
<div class="biab-or__grid">
|
|
1048
|
+
<div class="biab-or__side">
|
|
1049
|
+
<div class="biab-or__side-label">
|
|
1050
|
+
{cfg().leftLabel || "Option A"}
|
|
1051
|
+
</div>
|
|
1052
|
+
<Show when={left()}>
|
|
1053
|
+
{(l) => (
|
|
1054
|
+
<RenderTree fields={[l()]} ctx={props.ctx} />
|
|
1055
|
+
)}
|
|
1056
|
+
</Show>
|
|
1057
|
+
</div>
|
|
1058
|
+
<div class="biab-or__divider">
|
|
1059
|
+
<span class="biab-or__badge">OR</span>
|
|
1060
|
+
</div>
|
|
1061
|
+
<div class="biab-or__side">
|
|
1062
|
+
<div class="biab-or__side-label">
|
|
1063
|
+
{cfg().rightLabel || "Option B"}
|
|
1064
|
+
</div>
|
|
1065
|
+
<Show when={right()}>
|
|
1066
|
+
{(r) => (
|
|
1067
|
+
<RenderTree fields={[r()]} ctx={props.ctx} />
|
|
1068
|
+
)}
|
|
1069
|
+
</Show>
|
|
1070
|
+
</div>
|
|
1071
|
+
</div>
|
|
1072
|
+
<Show when={errored()}>
|
|
1073
|
+
<p class="biab-or__error">
|
|
1074
|
+
Fill in at least one side to continue.
|
|
1075
|
+
</p>
|
|
1076
|
+
</Show>
|
|
1077
|
+
</div>
|
|
1078
|
+
);
|
|
1079
|
+
})()}
|
|
1080
|
+
</Match>
|
|
1081
|
+
|
|
1082
|
+
<Match when={node.type === "flex_row"}>
|
|
1083
|
+
<div class="biab-flex-row">
|
|
1084
|
+
<For each={node.children ?? []}>
|
|
1085
|
+
{(child) => (
|
|
1086
|
+
<div class="biab-flex-row__cell">
|
|
1087
|
+
<FieldRow field={child} ctx={props.ctx} />
|
|
1088
|
+
</div>
|
|
1089
|
+
)}
|
|
1090
|
+
</For>
|
|
1091
|
+
</div>
|
|
1092
|
+
</Match>
|
|
1093
|
+
</Switch>
|
|
1094
|
+
)}
|
|
1095
|
+
</For>
|
|
1096
|
+
</div>
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1101
|
+
// Progress — labeled "steps" or thin "bar". Mirrors FormPreview's two indicators.
|
|
1102
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1103
|
+
|
|
1104
|
+
function StepProgress(props: { snapshot: Accessor<FormSnapshot> }): JSX.Element {
|
|
1105
|
+
const steps = () => props.snapshot().steps;
|
|
1106
|
+
const current = () => props.snapshot().step;
|
|
1107
|
+
return (
|
|
1108
|
+
<div class="biab-progress biab-progress--steps">
|
|
1109
|
+
<div class="biab-progress__title">Form Progress</div>
|
|
1110
|
+
<div class="biab-progress__track">
|
|
1111
|
+
<For each={steps()}>
|
|
1112
|
+
{(s, i) => {
|
|
1113
|
+
const head = () => s[0];
|
|
1114
|
+
const title = () =>
|
|
1115
|
+
head()?.stepTitle || head()?.label || `Step ${i() + 1}`;
|
|
1116
|
+
const done = () => i() < current();
|
|
1117
|
+
const isCurrent = () => i() === current();
|
|
1118
|
+
return (
|
|
1119
|
+
<>
|
|
1120
|
+
<Show when={i() > 0}>
|
|
1121
|
+
<div
|
|
1122
|
+
class="biab-progress__line"
|
|
1123
|
+
classList={{
|
|
1124
|
+
"biab-progress__line--filled": i() <= current(),
|
|
1125
|
+
}}
|
|
1126
|
+
/>
|
|
1127
|
+
</Show>
|
|
1128
|
+
<div class="biab-progress__node">
|
|
1129
|
+
<span
|
|
1130
|
+
class="biab-progress__dot"
|
|
1131
|
+
classList={{
|
|
1132
|
+
"biab-progress__dot--current": isCurrent(),
|
|
1133
|
+
"biab-progress__dot--done": done(),
|
|
1134
|
+
}}
|
|
1135
|
+
>
|
|
1136
|
+
{done() ? "✓" : i() + 1}
|
|
1137
|
+
</span>
|
|
1138
|
+
<span
|
|
1139
|
+
class="biab-progress__step-label"
|
|
1140
|
+
classList={{
|
|
1141
|
+
"biab-progress__step-label--current": isCurrent(),
|
|
1142
|
+
}}
|
|
1143
|
+
>
|
|
1144
|
+
{title()}
|
|
1145
|
+
</span>
|
|
1146
|
+
</div>
|
|
1147
|
+
</>
|
|
1148
|
+
);
|
|
1149
|
+
}}
|
|
1150
|
+
</For>
|
|
1151
|
+
</div>
|
|
1152
|
+
</div>
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function BarProgress(props: { snapshot: Accessor<FormSnapshot> }): JSX.Element {
|
|
1157
|
+
return (
|
|
1158
|
+
<div class="biab-progress biab-progress--bar">
|
|
1159
|
+
<span class="biab-progress__count">
|
|
1160
|
+
{props.snapshot().step + 1} / {props.snapshot().stepCount}
|
|
1161
|
+
</span>
|
|
1162
|
+
<div class="biab-progress__bar-track">
|
|
1163
|
+
<div
|
|
1164
|
+
class="biab-progress__bar-fill"
|
|
1165
|
+
style={{ width: `${props.snapshot().progressPercent}%` }}
|
|
1166
|
+
/>
|
|
1167
|
+
</div>
|
|
1168
|
+
</div>
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1173
|
+
// <BiabForm> — the drop-in component.
|
|
1174
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1175
|
+
|
|
1176
|
+
export interface BiabFormProps {
|
|
1177
|
+
/** Form slug — required unless a pre-fetched `schema` is supplied. */
|
|
1178
|
+
slug?: string;
|
|
1179
|
+
/** Pre-fetched schema (SSR / RSC). Adopted synchronously by the controller. */
|
|
1180
|
+
schema?: FormSchema;
|
|
1181
|
+
/** SDK client. Optional when a `<BiabProvider>` supplies one, or when only
|
|
1182
|
+
* rendering a pre-fetched `schema` without submitting. */
|
|
1183
|
+
client?: BiabClient;
|
|
1184
|
+
/** Submit-button text on the final step. */
|
|
1185
|
+
submitLabel?: string;
|
|
1186
|
+
/** Validate on the server but store nothing (no side effects). */
|
|
1187
|
+
dryRun?: boolean;
|
|
1188
|
+
/** Default submit options merged into every submit (call opts still win). */
|
|
1189
|
+
submitOptions?: FormSubmitOptions;
|
|
1190
|
+
/** Fired with the raw result on EVERY submit (success or failure). */
|
|
1191
|
+
onSubmit?: (result: FormSubmitResult) => void;
|
|
1192
|
+
/** Fired only on a successful submit. */
|
|
1193
|
+
onSuccess?: (result: Extract<FormSubmitResult, { ok: true }>) => void;
|
|
1194
|
+
/** Fired only on a failed submit. */
|
|
1195
|
+
onError?: (result: Extract<FormSubmitResult, { ok: false }>) => void;
|
|
1196
|
+
/** Per-field render override; return your own markup for any field. */
|
|
1197
|
+
field?: (args: FieldSlotArgs) => JSX.Element;
|
|
1198
|
+
/** Global HTML autofill switch. Defaults to inferring a token per field;
|
|
1199
|
+
* `false` emits `autocomplete="off"` on every input. Override individual
|
|
1200
|
+
* fields with `fieldAutoComplete`. */
|
|
1201
|
+
autoComplete?: boolean;
|
|
1202
|
+
/** Per-field autofill overrides keyed by field id: a literal token, `true`
|
|
1203
|
+
* (infer), or `false` (off). Wins over `autoComplete` and any schema token. */
|
|
1204
|
+
fieldAutoComplete?: Record<string, string | boolean>;
|
|
1205
|
+
/** Extra class on the form root (alias: `className`). */
|
|
1206
|
+
class?: string;
|
|
1207
|
+
className?: string;
|
|
1208
|
+
style?: JSX.CSSProperties | string;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* `<BiabForm slug="contact" client={client} />` — a complete, themeable form for
|
|
1213
|
+
* a published BIAB form. Loads the schema, renders the field tree, validates
|
|
1214
|
+
* locally (mirroring the server), handles multi-step nav + progress + concurrent
|
|
1215
|
+
* reveal + file uploads, and submits through the SDK.
|
|
1216
|
+
*
|
|
1217
|
+
* All visual classes are `biab-*` on light DOM, so consumer CSS cascades. The
|
|
1218
|
+
* component ships no styles of its own.
|
|
1219
|
+
*/
|
|
1220
|
+
export function BiabForm(props: BiabFormProps): JSX.Element {
|
|
1221
|
+
const providerClient = useBiabClient();
|
|
1222
|
+
const resolvedClient = () => props.client ?? providerClient;
|
|
1223
|
+
const formId = createUniqueId();
|
|
1224
|
+
|
|
1225
|
+
const store = createFormStore({
|
|
1226
|
+
...(resolvedClient() ? { client: resolvedClient() } : {}),
|
|
1227
|
+
...(props.slug ? { slug: props.slug } : {}),
|
|
1228
|
+
...(props.schema ? { schema: props.schema } : {}),
|
|
1229
|
+
submitOptions: {
|
|
1230
|
+
...props.submitOptions,
|
|
1231
|
+
...(props.dryRun ? { dryRun: true } : {}),
|
|
1232
|
+
},
|
|
1233
|
+
onSuccess: (result) => {
|
|
1234
|
+
props.onSuccess?.(result);
|
|
1235
|
+
props.onSubmit?.(result);
|
|
1236
|
+
},
|
|
1237
|
+
onError: (result) => {
|
|
1238
|
+
props.onError?.(result);
|
|
1239
|
+
props.onSubmit?.(result);
|
|
1240
|
+
},
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
const snap = store.snapshot;
|
|
1244
|
+
const settings = () => snap().settings;
|
|
1245
|
+
const isConsecutive = () => settings().isConsecutive;
|
|
1246
|
+
const multiStep = () => isConsecutive() && snap().stepCount > 1;
|
|
1247
|
+
|
|
1248
|
+
// Global + per-field HTML autofill config, built from props and resolved per
|
|
1249
|
+
// field at render time. See forms-core/autocomplete.ts.
|
|
1250
|
+
const autocompleteConfig = createMemo<AutocompleteConfig>(() => ({
|
|
1251
|
+
...(props.autoComplete !== undefined ? { enabled: props.autoComplete } : {}),
|
|
1252
|
+
...(props.fieldAutoComplete ? { overrides: props.fieldAutoComplete } : {}),
|
|
1253
|
+
}));
|
|
1254
|
+
|
|
1255
|
+
const ctx: RenderCtx = {
|
|
1256
|
+
snapshot: snap,
|
|
1257
|
+
setValue: (id, value) => store.controller.setValue(id, value),
|
|
1258
|
+
upload: (fieldId, file, filename) =>
|
|
1259
|
+
store.controller.uploadFile(fieldId, file, filename ? { filename } : undefined),
|
|
1260
|
+
autocomplete: (field) =>
|
|
1261
|
+
resolveFieldAutocomplete(field, autocompleteConfig()),
|
|
1262
|
+
...(props.field ? { fieldSlot: props.field } : {}),
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
const handleSubmit = (e: Event) => {
|
|
1266
|
+
e.preventDefault();
|
|
1267
|
+
void store.controller.submit();
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
const rootClass = () =>
|
|
1271
|
+
["biab-form", props.class, props.className].filter(Boolean).join(" ");
|
|
1272
|
+
|
|
1273
|
+
// Entrance-animation class for the active step (slide is directional).
|
|
1274
|
+
const animClass = () => {
|
|
1275
|
+
const a = settings().animation;
|
|
1276
|
+
const forward = snap().forward;
|
|
1277
|
+
switch (a) {
|
|
1278
|
+
case "fade":
|
|
1279
|
+
return "biab-anim biab-anim--fade";
|
|
1280
|
+
case "scale":
|
|
1281
|
+
return "biab-anim biab-anim--scale";
|
|
1282
|
+
case "slide":
|
|
1283
|
+
return forward
|
|
1284
|
+
? "biab-anim biab-anim--slide-forward"
|
|
1285
|
+
: "biab-anim biab-anim--slide-back";
|
|
1286
|
+
case "scroll":
|
|
1287
|
+
return "biab-anim biab-anim--scroll";
|
|
1288
|
+
default:
|
|
1289
|
+
return "";
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
return (
|
|
1294
|
+
<Switch>
|
|
1295
|
+
{/* Loading / error / empty guards mirror the controller's status. */}
|
|
1296
|
+
<Match when={snap().status === "loading"}>
|
|
1297
|
+
<div class="biab-form biab-form--loading" aria-busy="true">
|
|
1298
|
+
Loading…
|
|
1299
|
+
</div>
|
|
1300
|
+
</Match>
|
|
1301
|
+
<Match when={snap().status === "error"}>
|
|
1302
|
+
<div class="biab-form biab-form--error" role="alert">
|
|
1303
|
+
{snap().loadError ?? "Failed to load the form."}
|
|
1304
|
+
</div>
|
|
1305
|
+
</Match>
|
|
1306
|
+
<Match when={snap().status === "ready"}>
|
|
1307
|
+
<form
|
|
1308
|
+
id={formId}
|
|
1309
|
+
class={rootClass()}
|
|
1310
|
+
style={props.style}
|
|
1311
|
+
onSubmit={handleSubmit}
|
|
1312
|
+
novalidate
|
|
1313
|
+
>
|
|
1314
|
+
{/* Org icon (showOrgIcon) — top-right corner mark. */}
|
|
1315
|
+
<Show when={snap().orgIcon}>
|
|
1316
|
+
{(icon) => (
|
|
1317
|
+
<img class="biab-form__org-icon" src={icon()} alt="" />
|
|
1318
|
+
)}
|
|
1319
|
+
</Show>
|
|
1320
|
+
|
|
1321
|
+
{/* Title / description from the schema. */}
|
|
1322
|
+
<Show when={snap().title || snap().description}>
|
|
1323
|
+
<div class="biab-form__header">
|
|
1324
|
+
<Show when={snap().title}>
|
|
1325
|
+
<h2 class="biab-form__title">{snap().title}</h2>
|
|
1326
|
+
</Show>
|
|
1327
|
+
<Show when={snap().description}>
|
|
1328
|
+
<p class="biab-form__description">{snap().description}</p>
|
|
1329
|
+
</Show>
|
|
1330
|
+
</div>
|
|
1331
|
+
</Show>
|
|
1332
|
+
|
|
1333
|
+
{/* Multi-step progress indicator. */}
|
|
1334
|
+
<Show when={multiStep() && settings().showProgress}>
|
|
1335
|
+
<Show
|
|
1336
|
+
when={settings().progressStyle === "bar"}
|
|
1337
|
+
fallback={<StepProgress snapshot={snap} />}
|
|
1338
|
+
>
|
|
1339
|
+
<BarProgress snapshot={snap} />
|
|
1340
|
+
</Show>
|
|
1341
|
+
</Show>
|
|
1342
|
+
|
|
1343
|
+
{/* Form-level error summary. */}
|
|
1344
|
+
<Show when={snap().errorList.length > 0}>
|
|
1345
|
+
<div class="biab-form__errors" role="alert">
|
|
1346
|
+
<div class="biab-form__errors-title">
|
|
1347
|
+
Please complete the highlighted required field
|
|
1348
|
+
{snap().errorList.length === 1 ? "" : "s"}.
|
|
1349
|
+
</div>
|
|
1350
|
+
<div class="biab-form__errors-list">
|
|
1351
|
+
{snap()
|
|
1352
|
+
.errorList.map((er) => er.label)
|
|
1353
|
+
.join(", ")}
|
|
1354
|
+
</div>
|
|
1355
|
+
</div>
|
|
1356
|
+
</Show>
|
|
1357
|
+
|
|
1358
|
+
{/* The field tree for the active step (single-page = all fields).
|
|
1359
|
+
Keyed on step index so the entrance animation re-runs per step. */}
|
|
1360
|
+
<Show
|
|
1361
|
+
when={settings().animation === "scroll"}
|
|
1362
|
+
fallback={
|
|
1363
|
+
<div class={animClass()} data-step={snap().step}>
|
|
1364
|
+
<RenderTree
|
|
1365
|
+
fields={snap().visibleFields}
|
|
1366
|
+
ctx={ctx}
|
|
1367
|
+
flat={isConsecutive()}
|
|
1368
|
+
/>
|
|
1369
|
+
</div>
|
|
1370
|
+
}
|
|
1371
|
+
>
|
|
1372
|
+
<div class="biab-form__scroll">
|
|
1373
|
+
<For each={snap().visibleFields}>
|
|
1374
|
+
{(vf) => (
|
|
1375
|
+
<div class="biab-anim biab-anim--scroll">
|
|
1376
|
+
<RenderTree
|
|
1377
|
+
fields={[vf]}
|
|
1378
|
+
ctx={ctx}
|
|
1379
|
+
flat={isConsecutive()}
|
|
1380
|
+
/>
|
|
1381
|
+
</div>
|
|
1382
|
+
)}
|
|
1383
|
+
</For>
|
|
1384
|
+
</div>
|
|
1385
|
+
</Show>
|
|
1386
|
+
|
|
1387
|
+
{/* Navigation / submit. */}
|
|
1388
|
+
<Show
|
|
1389
|
+
when={multiStep()}
|
|
1390
|
+
fallback={
|
|
1391
|
+
<div class="biab-nav">
|
|
1392
|
+
<button
|
|
1393
|
+
type="submit"
|
|
1394
|
+
class="biab-btn biab-btn--submit"
|
|
1395
|
+
disabled={snap().submitting}
|
|
1396
|
+
>
|
|
1397
|
+
{snap().submitting
|
|
1398
|
+
? "Submitting…"
|
|
1399
|
+
: (props.submitLabel ?? "Submit")}
|
|
1400
|
+
</button>
|
|
1401
|
+
</div>
|
|
1402
|
+
}
|
|
1403
|
+
>
|
|
1404
|
+
<div class="biab-nav biab-nav--steps">
|
|
1405
|
+
<button
|
|
1406
|
+
type="button"
|
|
1407
|
+
class="biab-btn biab-btn--back"
|
|
1408
|
+
disabled={snap().isFirstStep}
|
|
1409
|
+
onClick={() => store.controller.back()}
|
|
1410
|
+
>
|
|
1411
|
+
Back
|
|
1412
|
+
</button>
|
|
1413
|
+
<Show
|
|
1414
|
+
when={snap().isLastStep}
|
|
1415
|
+
fallback={
|
|
1416
|
+
<button
|
|
1417
|
+
type="button"
|
|
1418
|
+
class="biab-btn biab-btn--next"
|
|
1419
|
+
disabled={!snap().canAdvance}
|
|
1420
|
+
onClick={() => store.controller.next()}
|
|
1421
|
+
>
|
|
1422
|
+
Next
|
|
1423
|
+
</button>
|
|
1424
|
+
}
|
|
1425
|
+
>
|
|
1426
|
+
<button
|
|
1427
|
+
type="submit"
|
|
1428
|
+
class="biab-btn biab-btn--submit"
|
|
1429
|
+
disabled={snap().submitting}
|
|
1430
|
+
>
|
|
1431
|
+
{snap().submitting
|
|
1432
|
+
? "Submitting…"
|
|
1433
|
+
: (props.submitLabel ?? "Submit")}
|
|
1434
|
+
</button>
|
|
1435
|
+
</Show>
|
|
1436
|
+
</div>
|
|
1437
|
+
</Show>
|
|
1438
|
+
|
|
1439
|
+
{/* Success state. */}
|
|
1440
|
+
<Show when={snap().submitted}>
|
|
1441
|
+
<div class="biab-form__success" role="status">
|
|
1442
|
+
Thanks — your response was received.
|
|
1443
|
+
</div>
|
|
1444
|
+
</Show>
|
|
1445
|
+
</form>
|
|
1446
|
+
</Match>
|
|
1447
|
+
</Switch>
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// Re-export the core types a consumer commonly annotates against, so they can
|
|
1452
|
+
// import everything they need from `@businessdash/sdk/solid` alone.
|
|
1453
|
+
export type {
|
|
1454
|
+
BiabClient,
|
|
1455
|
+
FormSchema,
|
|
1456
|
+
FormSubmitResult,
|
|
1457
|
+
FormFileValue,
|
|
1458
|
+
} from "./sdk.js";
|
|
1459
|
+
export type {
|
|
1460
|
+
FieldError,
|
|
1461
|
+
FormController,
|
|
1462
|
+
FormCoreField,
|
|
1463
|
+
FormSnapshot,
|
|
1464
|
+
FormSubmitOptions,
|
|
1465
|
+
} from "./forms-core/index.js";
|
|
1466
|
+
export { createFormController } from "./forms-core/index.js";
|