@marianmeres/stuic 3.126.1 → 3.127.0
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/dist/components/ContactUsForm/ContactUsForm.svelte +571 -0
- package/dist/components/ContactUsForm/ContactUsForm.svelte.d.ts +101 -0
- package/dist/components/ContactUsForm/README.md +211 -0
- package/dist/components/ContactUsForm/_internal/contact-form-i18n-defaults.d.ts +1 -0
- package/dist/components/ContactUsForm/_internal/contact-form-i18n-defaults.js +36 -0
- package/dist/components/ContactUsForm/_internal/contact-form-types.d.ts +59 -0
- package/dist/components/ContactUsForm/_internal/contact-form-types.js +1 -0
- package/dist/components/ContactUsForm/_internal/contact-form-utils.d.ts +17 -0
- package/dist/components/ContactUsForm/_internal/contact-form-utils.js +66 -0
- package/dist/components/ContactUsForm/index.css +22 -0
- package/dist/components/ContactUsForm/index.d.ts +4 -0
- package/dist/components/ContactUsForm/index.js +2 -0
- package/dist/components/Input/Honeypot.svelte +94 -0
- package/dist/components/Input/Honeypot.svelte.d.ts +40 -0
- package/dist/components/Input/README.md +69 -0
- package/dist/components/Input/TimeTrap.svelte +117 -0
- package/dist/components/Input/TimeTrap.svelte.d.ts +52 -0
- package/dist/components/Input/index.d.ts +2 -0
- package/dist/components/Input/index.js +2 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
4
|
+
import type { TranslateFn } from "../../types.js";
|
|
5
|
+
import type {
|
|
6
|
+
ContactBotCheck,
|
|
7
|
+
ContactFieldConfig,
|
|
8
|
+
ContactFormData,
|
|
9
|
+
ContactFormValidationError,
|
|
10
|
+
} from "./_internal/contact-form-types.js";
|
|
11
|
+
import type { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
|
|
12
|
+
|
|
13
|
+
export interface Props extends Omit<HTMLAttributes<HTMLFormElement>, "children"> {
|
|
14
|
+
/** Bindable contact data. Default: createEmptyContactFormData() */
|
|
15
|
+
formData?: ContactFormData;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Called on submit after client-side validation passes.
|
|
19
|
+
* `botCheck` carries the anti-bot signals (report-only — enforce them
|
|
20
|
+
* server-side; the form never blocks a submit because of them).
|
|
21
|
+
*/
|
|
22
|
+
onSubmit: (data: ContactFormData, botCheck: ContactBotCheck) => void;
|
|
23
|
+
|
|
24
|
+
/** Whether the form is currently submitting (disables CTA). */
|
|
25
|
+
isSubmitting?: boolean;
|
|
26
|
+
|
|
27
|
+
/** Field-specific validation errors (e.g. from the server). */
|
|
28
|
+
errors?: ContactFormValidationError[];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* General error message (not field-specific). Rendered as an alert box
|
|
32
|
+
* above the form. Example: "Something went wrong, please try again."
|
|
33
|
+
*/
|
|
34
|
+
error?: string;
|
|
35
|
+
|
|
36
|
+
/** Show the Name field. Default: false */
|
|
37
|
+
showName?: boolean;
|
|
38
|
+
/** Require the Name field (only applies when shown). Default: true */
|
|
39
|
+
requireName?: boolean;
|
|
40
|
+
/** Show the Phone field. Default: false */
|
|
41
|
+
showPhone?: boolean;
|
|
42
|
+
/** Require the Phone field (only applies when shown). Default: false */
|
|
43
|
+
requirePhone?: boolean;
|
|
44
|
+
/** Show the Subject field. Default: false */
|
|
45
|
+
showSubject?: boolean;
|
|
46
|
+
/** Require the Subject field (only applies when shown). Default: false */
|
|
47
|
+
requireSubject?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* When provided (non-empty), the Subject renders as a `<select>` of these
|
|
50
|
+
* values instead of a free-text input, and the subject field is shown
|
|
51
|
+
* regardless of `showSubject`. A blank "select…" prompt is prepended so the
|
|
52
|
+
* initial empty value isn't silently auto-selected. The bound value is still
|
|
53
|
+
* the chosen string in `formData.subject`.
|
|
54
|
+
*/
|
|
55
|
+
subjectValues?: string[];
|
|
56
|
+
/** Show the Company field. Default: false */
|
|
57
|
+
showCompany?: boolean;
|
|
58
|
+
/** Require the Company field (only applies when shown). Default: false */
|
|
59
|
+
requireCompany?: boolean;
|
|
60
|
+
/** Minimum message length. 0 (default) disables the check. */
|
|
61
|
+
messageMinLength?: number;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Declarative extra fields rendered as FieldInput entries.
|
|
65
|
+
* Values bind into `formData.extra[name]`.
|
|
66
|
+
*/
|
|
67
|
+
extraFields?: ContactFieldConfig[];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Escape hatch for non-FieldInput extras (consent checkbox, custom widgets).
|
|
71
|
+
* Rendered after the declarative extras and before the submit button.
|
|
72
|
+
* Receives `formData` (bindable) and a `fieldError(name)` lookup.
|
|
73
|
+
*/
|
|
74
|
+
extraFieldsSlot?: Snippet<
|
|
75
|
+
[
|
|
76
|
+
{
|
|
77
|
+
formData: ContactFormData;
|
|
78
|
+
fieldError: (name: string) => string | undefined;
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
>;
|
|
82
|
+
|
|
83
|
+
/** Render the hidden honeypot trap. Default: true */
|
|
84
|
+
useHoneypot?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Honeypot field name — tempting to bots but NOT a browser-autofill token
|
|
87
|
+
* (avoid url/website/email/name/phone/address). Default: "link".
|
|
88
|
+
*/
|
|
89
|
+
honeypotName?: string;
|
|
90
|
+
/** Render the time-trap (rejects suspiciously fast submits). Default: true */
|
|
91
|
+
useTimeTrap?: boolean;
|
|
92
|
+
/** Minimum expected fill time (ms) before a submit looks human. Default: 2000 */
|
|
93
|
+
timeTrapMinMs?: number;
|
|
94
|
+
|
|
95
|
+
/** Override CTA label. */
|
|
96
|
+
submitLabel?: string;
|
|
97
|
+
/** Override CTA label while submitting. */
|
|
98
|
+
submittingLabel?: string;
|
|
99
|
+
/** Override the CTA section. */
|
|
100
|
+
submitButton?: Snippet<[{ isSubmitting: boolean; disabled: boolean }]>;
|
|
101
|
+
|
|
102
|
+
/** Content below the form (legal text, alternative contact links, etc.). */
|
|
103
|
+
footer?: Snippet;
|
|
104
|
+
|
|
105
|
+
/** Optional notifications instance — `error` is also sent via notifications.error(). */
|
|
106
|
+
notifications?: NotificationsStack;
|
|
107
|
+
|
|
108
|
+
t?: TranslateFn;
|
|
109
|
+
unstyled?: boolean;
|
|
110
|
+
class?: string;
|
|
111
|
+
el?: HTMLFormElement;
|
|
112
|
+
}
|
|
113
|
+
</script>
|
|
114
|
+
|
|
115
|
+
<script lang="ts">
|
|
116
|
+
import { untrack } from "svelte";
|
|
117
|
+
import { twMerge } from "../../utils/tw-merge.js";
|
|
118
|
+
import { t_default } from "./_internal/contact-form-i18n-defaults.js";
|
|
119
|
+
import {
|
|
120
|
+
createEmptyContactFormData,
|
|
121
|
+
validateContactForm,
|
|
122
|
+
} from "./_internal/contact-form-utils.js";
|
|
123
|
+
import Button from "../Button/Button.svelte";
|
|
124
|
+
import DismissibleMessage from "../DismissibleMessage/DismissibleMessage.svelte";
|
|
125
|
+
import FieldInput from "../Input/FieldInput.svelte";
|
|
126
|
+
import FieldSelect from "../Input/FieldSelect.svelte";
|
|
127
|
+
import FieldTextarea from "../Input/FieldTextarea.svelte";
|
|
128
|
+
import Honeypot from "../Input/Honeypot.svelte";
|
|
129
|
+
import TimeTrap from "../Input/TimeTrap.svelte";
|
|
130
|
+
import { onSubmitValidityCheck } from "../../actions/on-submit-validity-check.svelte.js";
|
|
131
|
+
import {
|
|
132
|
+
scrollToFirstInvalidField,
|
|
133
|
+
validateAllFields,
|
|
134
|
+
} from "../../utils/validate-fields.js";
|
|
135
|
+
|
|
136
|
+
let {
|
|
137
|
+
formData = $bindable(createEmptyContactFormData()),
|
|
138
|
+
onSubmit,
|
|
139
|
+
isSubmitting = false,
|
|
140
|
+
errors: externalErrors = [],
|
|
141
|
+
error,
|
|
142
|
+
showName = false,
|
|
143
|
+
requireName = true,
|
|
144
|
+
showPhone = false,
|
|
145
|
+
requirePhone = false,
|
|
146
|
+
showSubject = false,
|
|
147
|
+
requireSubject = false,
|
|
148
|
+
subjectValues,
|
|
149
|
+
showCompany = false,
|
|
150
|
+
requireCompany = false,
|
|
151
|
+
messageMinLength = 0,
|
|
152
|
+
extraFields = [],
|
|
153
|
+
extraFieldsSlot,
|
|
154
|
+
useHoneypot = true,
|
|
155
|
+
honeypotName = "link",
|
|
156
|
+
useTimeTrap = true,
|
|
157
|
+
timeTrapMinMs = 2000,
|
|
158
|
+
submitLabel,
|
|
159
|
+
submittingLabel,
|
|
160
|
+
submitButton,
|
|
161
|
+
footer,
|
|
162
|
+
notifications,
|
|
163
|
+
t: tProp,
|
|
164
|
+
unstyled = false,
|
|
165
|
+
class: classProp,
|
|
166
|
+
el = $bindable(),
|
|
167
|
+
...rest
|
|
168
|
+
}: Props = $props();
|
|
169
|
+
|
|
170
|
+
let t = $derived(tProp ?? t_default);
|
|
171
|
+
|
|
172
|
+
// Mirror the form ref into local $state so it survives prop re-application
|
|
173
|
+
// when the parent re-renders without binding `el`. Same Svelte 5 `$bindable`
|
|
174
|
+
// + `bind:this` gotcha that LoginForm / RegisterForm document.
|
|
175
|
+
let formEl = $state<HTMLFormElement | undefined>();
|
|
176
|
+
$effect(() => {
|
|
177
|
+
el = formEl;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
let topFields = $derived(extraFields.filter((f) => f.position === "top"));
|
|
181
|
+
let bottomFields = $derived(extraFields.filter((f) => f.position !== "top"));
|
|
182
|
+
|
|
183
|
+
// Subject: render a <select> when subjectValues is non-empty (which also shows
|
|
184
|
+
// the field regardless of showSubject); otherwise a free-text input gated by
|
|
185
|
+
// showSubject. The select gets a prepended blank "prompt" option so the initial
|
|
186
|
+
// empty subject isn't silently auto-selected to the first real value.
|
|
187
|
+
let subjectAsSelect = $derived((subjectValues?.length ?? 0) > 0);
|
|
188
|
+
let subjectShown = $derived(showSubject || subjectAsSelect);
|
|
189
|
+
let subjectOptions = $derived([
|
|
190
|
+
{ label: t("contact_form.subject_select_prompt"), value: "" },
|
|
191
|
+
...(subjectValues ?? []).map((v) => ({ label: v, value: v })),
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
// Bot-protection state (kept out of formData — surfaced via botCheck).
|
|
195
|
+
let honeypotValue = $state("");
|
|
196
|
+
let timeTrap = $state<TimeTrap>();
|
|
197
|
+
let ttTooFast = $state(true);
|
|
198
|
+
let ttElapsed = $state(0);
|
|
199
|
+
|
|
200
|
+
// Internal validation errors (set on submit).
|
|
201
|
+
let internalErrors = $state<ContactFormValidationError[]>([]);
|
|
202
|
+
|
|
203
|
+
// Clear internal field errors as soon as the user edits any tracked field, so
|
|
204
|
+
// a previous failed submit's errors don't linger after the user has fixed
|
|
205
|
+
// them. `untrack` the read+write so this effect re-runs only on formData
|
|
206
|
+
// changes (not when handleSubmitValid sets internalErrors).
|
|
207
|
+
$effect(() => {
|
|
208
|
+
void formData.name;
|
|
209
|
+
void formData.email;
|
|
210
|
+
void formData.phone;
|
|
211
|
+
void formData.subject;
|
|
212
|
+
void formData.company;
|
|
213
|
+
void formData.message;
|
|
214
|
+
for (const f of extraFields) void formData.extra?.[f.name];
|
|
215
|
+
untrack(() => {
|
|
216
|
+
if (internalErrors.length) internalErrors = [];
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Merge internal + external errors; external takes precedence per field.
|
|
221
|
+
let allErrors = $derived.by(() => {
|
|
222
|
+
const map = new Map<string, string>();
|
|
223
|
+
for (const e of internalErrors) map.set(e.field, e.message);
|
|
224
|
+
for (const e of externalErrors) map.set(e.field, e.message);
|
|
225
|
+
return [...map.entries()].map(([field, message]) => ({ field, message }));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
function fieldError(field: string): string | undefined {
|
|
229
|
+
return allErrors.find((e) => e.field === field)?.message;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function extraValue(cfg: ContactFieldConfig): string {
|
|
233
|
+
const v = formData.extra?.[cfg.name];
|
|
234
|
+
if (v == null) return typeof cfg.initialValue === "string" ? cfg.initialValue : "";
|
|
235
|
+
return typeof v === "string" ? v : String(v);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function setExtraValue(cfg: ContactFieldConfig, value: string) {
|
|
239
|
+
if (!formData.extra) formData.extra = {};
|
|
240
|
+
formData.extra[cfg.name] = value;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function buildBotCheck(): ContactBotCheck {
|
|
244
|
+
const snap = useTimeTrap
|
|
245
|
+
? (timeTrap?.check() ?? { elapsedMs: ttElapsed, isTooFast: ttTooFast })
|
|
246
|
+
: { elapsedMs: 0, isTooFast: false };
|
|
247
|
+
const honeypot = useHoneypot ? honeypotValue : "";
|
|
248
|
+
const honeypotFilled = useHoneypot && honeypot.trim() !== "";
|
|
249
|
+
const isTooFast = useTimeTrap && !!snap.isTooFast;
|
|
250
|
+
return {
|
|
251
|
+
honeypot,
|
|
252
|
+
honeypotFilled,
|
|
253
|
+
elapsedMs: snap.elapsedMs ?? 0,
|
|
254
|
+
isTooFast,
|
|
255
|
+
minMs: timeTrapMinMs,
|
|
256
|
+
isLikelyBot: honeypotFilled || isTooFast,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function handleSubmitValid() {
|
|
261
|
+
const validationErrors = validateContactForm(formData, t, {
|
|
262
|
+
showName,
|
|
263
|
+
requireName,
|
|
264
|
+
showPhone,
|
|
265
|
+
requirePhone,
|
|
266
|
+
showSubject: subjectShown,
|
|
267
|
+
requireSubject,
|
|
268
|
+
showCompany,
|
|
269
|
+
requireCompany,
|
|
270
|
+
messageMinLength,
|
|
271
|
+
extraFields,
|
|
272
|
+
});
|
|
273
|
+
internalErrors = validationErrors;
|
|
274
|
+
|
|
275
|
+
// Report-only on bot signals: we still submit when field validation passes
|
|
276
|
+
// and hand the consumer the botCheck to enforce server-side.
|
|
277
|
+
if (validationErrors.length === 0 && externalErrors.length === 0) {
|
|
278
|
+
onSubmit(formData, buildBotCheck());
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
$effect(() => {
|
|
283
|
+
if (error && notifications) notifications.error(error);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// onSubmitValidityCheck intercepts native submit and dispatches "submit_valid".
|
|
287
|
+
// Bind to `formEl` (local state) — NOT the `el` prop, which can revert to
|
|
288
|
+
// undefined on parent re-render and would silently detach the listener.
|
|
289
|
+
$effect(() => {
|
|
290
|
+
const node = formEl;
|
|
291
|
+
if (!node) return;
|
|
292
|
+
node.addEventListener("submit_valid", handleSubmitValid);
|
|
293
|
+
return () => node.removeEventListener("submit_valid", handleSubmitValid);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
let _class = $derived(
|
|
297
|
+
unstyled ? classProp : twMerge("stuic-contact-us-form", classProp)
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Imperative API ----------------------------------------------------------
|
|
301
|
+
let topFieldRefs: (FieldInput | undefined)[] = $state([]);
|
|
302
|
+
let bottomFieldRefs: (FieldInput | undefined)[] = $state([]);
|
|
303
|
+
let nameField = $state<FieldInput>();
|
|
304
|
+
let emailField = $state<FieldInput>();
|
|
305
|
+
let phoneField = $state<FieldInput>();
|
|
306
|
+
let companyField = $state<FieldInput>();
|
|
307
|
+
let subjectField = $state<FieldInput | FieldSelect>();
|
|
308
|
+
let messageField = $state<FieldTextarea>();
|
|
309
|
+
|
|
310
|
+
function _fields() {
|
|
311
|
+
return [
|
|
312
|
+
...topFieldRefs,
|
|
313
|
+
...(showName ? [nameField] : []),
|
|
314
|
+
emailField,
|
|
315
|
+
...(showPhone ? [phoneField] : []),
|
|
316
|
+
...(showCompany ? [companyField] : []),
|
|
317
|
+
...(subjectShown ? [subjectField] : []),
|
|
318
|
+
messageField,
|
|
319
|
+
...bottomFieldRefs,
|
|
320
|
+
];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Run every visible field's validator and render any inline errors. Returns
|
|
325
|
+
* true if all fields are valid. Useful from custom submit handlers.
|
|
326
|
+
*/
|
|
327
|
+
export function validate(): boolean {
|
|
328
|
+
return validateAllFields(_fields());
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Scroll the first invalid field into view and focus it. Returns true if a
|
|
333
|
+
* field was scrolled. Call after `validate()`.
|
|
334
|
+
*/
|
|
335
|
+
export function scrollToFirstError(
|
|
336
|
+
opts?: Parameters<typeof scrollToFirstInvalidField>[1]
|
|
337
|
+
): boolean {
|
|
338
|
+
return scrollToFirstInvalidField(_fields(), opts);
|
|
339
|
+
}
|
|
340
|
+
</script>
|
|
341
|
+
|
|
342
|
+
<form bind:this={formEl} class={_class} use:onSubmitValidityCheck novalidate {...rest}>
|
|
343
|
+
<!-- General error alert -->
|
|
344
|
+
<DismissibleMessage message={error} intent="destructive" />
|
|
345
|
+
|
|
346
|
+
<!-- Top-position extra fields -->
|
|
347
|
+
{#each topFields as cfg, i (cfg.name)}
|
|
348
|
+
<FieldInput
|
|
349
|
+
bind:this={topFieldRefs[i]}
|
|
350
|
+
value={extraValue(cfg)}
|
|
351
|
+
oninput={(e: Event) =>
|
|
352
|
+
setExtraValue(cfg, (e.currentTarget as HTMLInputElement).value)}
|
|
353
|
+
label={cfg.label}
|
|
354
|
+
type={cfg.type ?? "text"}
|
|
355
|
+
placeholder={cfg.placeholder}
|
|
356
|
+
autocomplete={cfg.autocomplete}
|
|
357
|
+
required={cfg.required}
|
|
358
|
+
name={`contact-extra-${cfg.name}`}
|
|
359
|
+
labelLeftBreakpoint={0}
|
|
360
|
+
validate={{
|
|
361
|
+
customValidator() {
|
|
362
|
+
return fieldError(cfg.name) || "";
|
|
363
|
+
},
|
|
364
|
+
}}
|
|
365
|
+
{...cfg.props}
|
|
366
|
+
/>
|
|
367
|
+
{/each}
|
|
368
|
+
|
|
369
|
+
<!--
|
|
370
|
+
NOTE on `binding_property_non_reactive`: formData is a $bindable prop — deep
|
|
371
|
+
reactivity depends on the consumer passing a $state() object. The bindings
|
|
372
|
+
work correctly regardless; the per-field directives below silence the hint.
|
|
373
|
+
(This block intentionally does NOT start with the literal directive word so
|
|
374
|
+
it isn't parsed as one.)
|
|
375
|
+
-->
|
|
376
|
+
<!-- Name -->
|
|
377
|
+
{#if showName}
|
|
378
|
+
<!-- svelte-ignore binding_property_non_reactive -->
|
|
379
|
+
<FieldInput
|
|
380
|
+
bind:this={nameField}
|
|
381
|
+
bind:value={formData.name}
|
|
382
|
+
label={t("contact_form.name_label")}
|
|
383
|
+
type="text"
|
|
384
|
+
placeholder={t("contact_form.name_placeholder")}
|
|
385
|
+
autocomplete="name"
|
|
386
|
+
required={requireName}
|
|
387
|
+
name="contact-name"
|
|
388
|
+
labelLeftBreakpoint={0}
|
|
389
|
+
validate={{
|
|
390
|
+
customValidator() {
|
|
391
|
+
return fieldError("name") || "";
|
|
392
|
+
},
|
|
393
|
+
}}
|
|
394
|
+
/>
|
|
395
|
+
{/if}
|
|
396
|
+
|
|
397
|
+
<!-- Email -->
|
|
398
|
+
<!-- svelte-ignore binding_property_non_reactive -->
|
|
399
|
+
<FieldInput
|
|
400
|
+
bind:this={emailField}
|
|
401
|
+
bind:value={formData.email}
|
|
402
|
+
label={t("contact_form.email_label")}
|
|
403
|
+
type="email"
|
|
404
|
+
placeholder={t("contact_form.email_placeholder")}
|
|
405
|
+
autocomplete="email"
|
|
406
|
+
required
|
|
407
|
+
name="contact-email"
|
|
408
|
+
labelLeftBreakpoint={0}
|
|
409
|
+
validate={{
|
|
410
|
+
customValidator() {
|
|
411
|
+
return fieldError("email") || "";
|
|
412
|
+
},
|
|
413
|
+
}}
|
|
414
|
+
/>
|
|
415
|
+
|
|
416
|
+
<!-- Phone -->
|
|
417
|
+
{#if showPhone}
|
|
418
|
+
<!-- svelte-ignore binding_property_non_reactive -->
|
|
419
|
+
<FieldInput
|
|
420
|
+
bind:this={phoneField}
|
|
421
|
+
bind:value={formData.phone}
|
|
422
|
+
label={t("contact_form.phone_label")}
|
|
423
|
+
type="tel"
|
|
424
|
+
placeholder={t("contact_form.phone_placeholder")}
|
|
425
|
+
autocomplete="tel"
|
|
426
|
+
required={requirePhone}
|
|
427
|
+
name="contact-phone"
|
|
428
|
+
labelLeftBreakpoint={0}
|
|
429
|
+
validate={{
|
|
430
|
+
customValidator() {
|
|
431
|
+
return fieldError("phone") || "";
|
|
432
|
+
},
|
|
433
|
+
}}
|
|
434
|
+
/>
|
|
435
|
+
{/if}
|
|
436
|
+
|
|
437
|
+
<!-- Company -->
|
|
438
|
+
{#if showCompany}
|
|
439
|
+
<!-- svelte-ignore binding_property_non_reactive -->
|
|
440
|
+
<FieldInput
|
|
441
|
+
bind:this={companyField}
|
|
442
|
+
bind:value={formData.company}
|
|
443
|
+
label={t("contact_form.company_label")}
|
|
444
|
+
type="text"
|
|
445
|
+
placeholder={t("contact_form.company_placeholder")}
|
|
446
|
+
autocomplete="organization"
|
|
447
|
+
required={requireCompany}
|
|
448
|
+
name="contact-company"
|
|
449
|
+
labelLeftBreakpoint={0}
|
|
450
|
+
validate={{
|
|
451
|
+
customValidator() {
|
|
452
|
+
return fieldError("company") || "";
|
|
453
|
+
},
|
|
454
|
+
}}
|
|
455
|
+
/>
|
|
456
|
+
{/if}
|
|
457
|
+
|
|
458
|
+
<!-- Subject — a <select> when subjectValues is provided, else free text -->
|
|
459
|
+
{#if subjectShown}
|
|
460
|
+
{#if subjectAsSelect}
|
|
461
|
+
<!-- svelte-ignore binding_property_non_reactive -->
|
|
462
|
+
<FieldSelect
|
|
463
|
+
bind:this={subjectField}
|
|
464
|
+
bind:value={formData.subject}
|
|
465
|
+
label={t("contact_form.subject_label")}
|
|
466
|
+
options={subjectOptions}
|
|
467
|
+
required={requireSubject}
|
|
468
|
+
name="contact-subject"
|
|
469
|
+
labelLeftBreakpoint={0}
|
|
470
|
+
validate={{
|
|
471
|
+
customValidator() {
|
|
472
|
+
return fieldError("subject") || "";
|
|
473
|
+
},
|
|
474
|
+
}}
|
|
475
|
+
/>
|
|
476
|
+
{:else}
|
|
477
|
+
<!-- svelte-ignore binding_property_non_reactive -->
|
|
478
|
+
<FieldInput
|
|
479
|
+
bind:this={subjectField}
|
|
480
|
+
bind:value={formData.subject}
|
|
481
|
+
label={t("contact_form.subject_label")}
|
|
482
|
+
type="text"
|
|
483
|
+
placeholder={t("contact_form.subject_placeholder")}
|
|
484
|
+
required={requireSubject}
|
|
485
|
+
name="contact-subject"
|
|
486
|
+
labelLeftBreakpoint={0}
|
|
487
|
+
validate={{
|
|
488
|
+
customValidator() {
|
|
489
|
+
return fieldError("subject") || "";
|
|
490
|
+
},
|
|
491
|
+
}}
|
|
492
|
+
/>
|
|
493
|
+
{/if}
|
|
494
|
+
{/if}
|
|
495
|
+
|
|
496
|
+
<!-- Message -->
|
|
497
|
+
<!-- svelte-ignore binding_property_non_reactive -->
|
|
498
|
+
<FieldTextarea
|
|
499
|
+
bind:this={messageField}
|
|
500
|
+
bind:value={formData.message}
|
|
501
|
+
label={t("contact_form.message_label")}
|
|
502
|
+
placeholder={t("contact_form.message_placeholder")}
|
|
503
|
+
required
|
|
504
|
+
name="contact-message"
|
|
505
|
+
labelLeftBreakpoint={0}
|
|
506
|
+
validate={{
|
|
507
|
+
customValidator() {
|
|
508
|
+
return fieldError("message") || "";
|
|
509
|
+
},
|
|
510
|
+
}}
|
|
511
|
+
/>
|
|
512
|
+
|
|
513
|
+
<!-- Bottom-position extra fields (default) -->
|
|
514
|
+
{#each bottomFields as cfg, i (cfg.name)}
|
|
515
|
+
<FieldInput
|
|
516
|
+
bind:this={bottomFieldRefs[i]}
|
|
517
|
+
value={extraValue(cfg)}
|
|
518
|
+
oninput={(e: Event) =>
|
|
519
|
+
setExtraValue(cfg, (e.currentTarget as HTMLInputElement).value)}
|
|
520
|
+
label={cfg.label}
|
|
521
|
+
type={cfg.type ?? "text"}
|
|
522
|
+
placeholder={cfg.placeholder}
|
|
523
|
+
autocomplete={cfg.autocomplete}
|
|
524
|
+
required={cfg.required}
|
|
525
|
+
name={`contact-extra-${cfg.name}`}
|
|
526
|
+
labelLeftBreakpoint={0}
|
|
527
|
+
validate={{
|
|
528
|
+
customValidator() {
|
|
529
|
+
return fieldError(cfg.name) || "";
|
|
530
|
+
},
|
|
531
|
+
}}
|
|
532
|
+
{...cfg.props}
|
|
533
|
+
/>
|
|
534
|
+
{/each}
|
|
535
|
+
|
|
536
|
+
<!-- Escape-hatch slot (consent checkbox, custom fields, etc.) -->
|
|
537
|
+
{#if extraFieldsSlot}
|
|
538
|
+
{@render extraFieldsSlot({ formData, fieldError })}
|
|
539
|
+
{/if}
|
|
540
|
+
|
|
541
|
+
<!-- Bot protection (rendered, but never blocks submit — see buildBotCheck) -->
|
|
542
|
+
{#if useHoneypot}
|
|
543
|
+
<Honeypot bind:value={honeypotValue} name={honeypotName} />
|
|
544
|
+
{/if}
|
|
545
|
+
{#if useTimeTrap}
|
|
546
|
+
<TimeTrap
|
|
547
|
+
bind:this={timeTrap}
|
|
548
|
+
bind:isTooFast={ttTooFast}
|
|
549
|
+
bind:elapsedMs={ttElapsed}
|
|
550
|
+
minMs={timeTrapMinMs}
|
|
551
|
+
/>
|
|
552
|
+
{/if}
|
|
553
|
+
|
|
554
|
+
<!-- CTA -->
|
|
555
|
+
{#if submitButton}
|
|
556
|
+
{@render submitButton({ isSubmitting, disabled: isSubmitting })}
|
|
557
|
+
{:else}
|
|
558
|
+
<div class={unstyled ? undefined : "stuic-contact-us-form-submit"}>
|
|
559
|
+
<Button intent="primary" type="submit" disabled={isSubmitting} class="w-full">
|
|
560
|
+
{isSubmitting
|
|
561
|
+
? (submittingLabel ?? t("contact_form.submitting"))
|
|
562
|
+
: (submitLabel ?? t("contact_form.submit"))}
|
|
563
|
+
</Button>
|
|
564
|
+
</div>
|
|
565
|
+
{/if}
|
|
566
|
+
|
|
567
|
+
<!-- Footer -->
|
|
568
|
+
{#if footer}
|
|
569
|
+
{@render footer()}
|
|
570
|
+
{/if}
|
|
571
|
+
</form>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
3
|
+
import type { TranslateFn } from "../../types.js";
|
|
4
|
+
import type { ContactBotCheck, ContactFieldConfig, ContactFormData, ContactFormValidationError } from "./_internal/contact-form-types.js";
|
|
5
|
+
import type { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
|
|
6
|
+
export interface Props extends Omit<HTMLAttributes<HTMLFormElement>, "children"> {
|
|
7
|
+
/** Bindable contact data. Default: createEmptyContactFormData() */
|
|
8
|
+
formData?: ContactFormData;
|
|
9
|
+
/**
|
|
10
|
+
* Called on submit after client-side validation passes.
|
|
11
|
+
* `botCheck` carries the anti-bot signals (report-only — enforce them
|
|
12
|
+
* server-side; the form never blocks a submit because of them).
|
|
13
|
+
*/
|
|
14
|
+
onSubmit: (data: ContactFormData, botCheck: ContactBotCheck) => void;
|
|
15
|
+
/** Whether the form is currently submitting (disables CTA). */
|
|
16
|
+
isSubmitting?: boolean;
|
|
17
|
+
/** Field-specific validation errors (e.g. from the server). */
|
|
18
|
+
errors?: ContactFormValidationError[];
|
|
19
|
+
/**
|
|
20
|
+
* General error message (not field-specific). Rendered as an alert box
|
|
21
|
+
* above the form. Example: "Something went wrong, please try again."
|
|
22
|
+
*/
|
|
23
|
+
error?: string;
|
|
24
|
+
/** Show the Name field. Default: false */
|
|
25
|
+
showName?: boolean;
|
|
26
|
+
/** Require the Name field (only applies when shown). Default: true */
|
|
27
|
+
requireName?: boolean;
|
|
28
|
+
/** Show the Phone field. Default: false */
|
|
29
|
+
showPhone?: boolean;
|
|
30
|
+
/** Require the Phone field (only applies when shown). Default: false */
|
|
31
|
+
requirePhone?: boolean;
|
|
32
|
+
/** Show the Subject field. Default: false */
|
|
33
|
+
showSubject?: boolean;
|
|
34
|
+
/** Require the Subject field (only applies when shown). Default: false */
|
|
35
|
+
requireSubject?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* When provided (non-empty), the Subject renders as a `<select>` of these
|
|
38
|
+
* values instead of a free-text input, and the subject field is shown
|
|
39
|
+
* regardless of `showSubject`. A blank "select…" prompt is prepended so the
|
|
40
|
+
* initial empty value isn't silently auto-selected. The bound value is still
|
|
41
|
+
* the chosen string in `formData.subject`.
|
|
42
|
+
*/
|
|
43
|
+
subjectValues?: string[];
|
|
44
|
+
/** Show the Company field. Default: false */
|
|
45
|
+
showCompany?: boolean;
|
|
46
|
+
/** Require the Company field (only applies when shown). Default: false */
|
|
47
|
+
requireCompany?: boolean;
|
|
48
|
+
/** Minimum message length. 0 (default) disables the check. */
|
|
49
|
+
messageMinLength?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Declarative extra fields rendered as FieldInput entries.
|
|
52
|
+
* Values bind into `formData.extra[name]`.
|
|
53
|
+
*/
|
|
54
|
+
extraFields?: ContactFieldConfig[];
|
|
55
|
+
/**
|
|
56
|
+
* Escape hatch for non-FieldInput extras (consent checkbox, custom widgets).
|
|
57
|
+
* Rendered after the declarative extras and before the submit button.
|
|
58
|
+
* Receives `formData` (bindable) and a `fieldError(name)` lookup.
|
|
59
|
+
*/
|
|
60
|
+
extraFieldsSlot?: Snippet<[
|
|
61
|
+
{
|
|
62
|
+
formData: ContactFormData;
|
|
63
|
+
fieldError: (name: string) => string | undefined;
|
|
64
|
+
}
|
|
65
|
+
]>;
|
|
66
|
+
/** Render the hidden honeypot trap. Default: true */
|
|
67
|
+
useHoneypot?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Honeypot field name — tempting to bots but NOT a browser-autofill token
|
|
70
|
+
* (avoid url/website/email/name/phone/address). Default: "link".
|
|
71
|
+
*/
|
|
72
|
+
honeypotName?: string;
|
|
73
|
+
/** Render the time-trap (rejects suspiciously fast submits). Default: true */
|
|
74
|
+
useTimeTrap?: boolean;
|
|
75
|
+
/** Minimum expected fill time (ms) before a submit looks human. Default: 2000 */
|
|
76
|
+
timeTrapMinMs?: number;
|
|
77
|
+
/** Override CTA label. */
|
|
78
|
+
submitLabel?: string;
|
|
79
|
+
/** Override CTA label while submitting. */
|
|
80
|
+
submittingLabel?: string;
|
|
81
|
+
/** Override the CTA section. */
|
|
82
|
+
submitButton?: Snippet<[{
|
|
83
|
+
isSubmitting: boolean;
|
|
84
|
+
disabled: boolean;
|
|
85
|
+
}]>;
|
|
86
|
+
/** Content below the form (legal text, alternative contact links, etc.). */
|
|
87
|
+
footer?: Snippet;
|
|
88
|
+
/** Optional notifications instance — `error` is also sent via notifications.error(). */
|
|
89
|
+
notifications?: NotificationsStack;
|
|
90
|
+
t?: TranslateFn;
|
|
91
|
+
unstyled?: boolean;
|
|
92
|
+
class?: string;
|
|
93
|
+
el?: HTMLFormElement;
|
|
94
|
+
}
|
|
95
|
+
import { scrollToFirstInvalidField } from "../../utils/validate-fields.js";
|
|
96
|
+
declare const ContactUsForm: import("svelte").Component<Props, {
|
|
97
|
+
validate: () => boolean;
|
|
98
|
+
scrollToFirstError: (opts?: Parameters<typeof scrollToFirstInvalidField>[1]) => boolean;
|
|
99
|
+
}, "el" | "formData">;
|
|
100
|
+
type ContactUsForm = ReturnType<typeof ContactUsForm>;
|
|
101
|
+
export default ContactUsForm;
|