@marianmeres/stuic 3.126.0 → 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,211 @@
|
|
|
1
|
+
# ContactUsForm
|
|
2
|
+
|
|
3
|
+
Drop-in "contact us" form built from the STUIC `Field*` primitives. Same conventions as [`RegisterForm`](../RegisterForm/README.md): bindable `formData`, an `onSubmit` callback, inline + server validation, i18n via `t`, optional `notifications`, declarative `extraFields`, and an `extraFieldsSlot` escape hatch.
|
|
4
|
+
|
|
5
|
+
By default it renders the **absolute minimum** — an **Email** and a **Message** field — and everything else is opt-in via `show*` toggles, `extraFields`, or the slot. There are **no server wirings**: you get the data (and the bot signals) in `onSubmit` and decide what to do with them.
|
|
6
|
+
|
|
7
|
+
## Bot protection
|
|
8
|
+
|
|
9
|
+
Two **client-side, server-less** anti-bot primitives are rendered by default and reused from the Input package — [`Honeypot`](../Input/README.md#honeypot--time-trap-anti-bot-primitives) and [`TimeTrap`](../Input/README.md#honeypot--time-trap-anti-bot-primitives):
|
|
10
|
+
|
|
11
|
+
- **Honeypot** — a hidden field real users never see; a non-empty value means a bot filled it.
|
|
12
|
+
- **TimeTrap** — flags submits that arrive faster than a human plausibly could (`timeTrapMinMs`, default 2000).
|
|
13
|
+
|
|
14
|
+
These are **report-only**: the form **never blocks** a submit because of them. Instead it hands you a `botCheck` object as the **second argument** to `onSubmit` — you enforce server-side (drop / rate-limit / queue for review). This is the right division of labor for a server-less component: the library provides the signals, your backend decides. For hard protection against targeted bots in 2026 you still want a real challenge (Cloudflare Turnstile / hCaptcha) verified on your server — render its widget through the `extraFieldsSlot`.
|
|
15
|
+
|
|
16
|
+
## Exports
|
|
17
|
+
|
|
18
|
+
| Export | Kind | Description |
|
|
19
|
+
| ---------------------------- | --------- | ---------------------------------------------------------- |
|
|
20
|
+
| `ContactUsForm` | component | The form component |
|
|
21
|
+
| `ContactUsFormProps` | type | Props for `ContactUsForm` |
|
|
22
|
+
| `ContactFormData` | type | `{ name, email, phone, subject, company, message, extra }` |
|
|
23
|
+
| `ContactBotCheck` | type | Anti-bot signals passed as the 2nd `onSubmit` arg |
|
|
24
|
+
| `ContactFormValidationError` | type | `{ field, message }` |
|
|
25
|
+
| `ContactFieldConfig` | type | Declarative extra-field descriptor |
|
|
26
|
+
| `createEmptyContactFormData` | function | Factory for an empty `ContactFormData` |
|
|
27
|
+
| `ValidateContactFormOptions` | type | Options accepted by the internal validator |
|
|
28
|
+
|
|
29
|
+
## `ContactBotCheck`
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
interface ContactBotCheck {
|
|
33
|
+
honeypot: string; // raw honeypot value ("" = clean)
|
|
34
|
+
honeypotFilled: boolean; // true when the trap was filled
|
|
35
|
+
elapsedMs: number; // ms between form mount and submit
|
|
36
|
+
isTooFast: boolean; // elapsedMs < minMs
|
|
37
|
+
minMs: number; // the configured timeTrapMinMs
|
|
38
|
+
isLikelyBot: boolean; // honeypotFilled || isTooFast
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## `ContactFieldConfig`
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
interface ContactFieldConfig {
|
|
46
|
+
name: string; // unique key under formData.extra
|
|
47
|
+
label: string; // already-translated
|
|
48
|
+
type?: "text" | "email" | "tel" | "url" | "number";
|
|
49
|
+
placeholder?: string;
|
|
50
|
+
required?: boolean;
|
|
51
|
+
autocomplete?: HTMLInputAttributes["autocomplete"];
|
|
52
|
+
initialValue?: unknown;
|
|
53
|
+
validate?: (value: unknown, data: ContactFormData) => string | undefined;
|
|
54
|
+
position?: "top" | "bottom"; // default "bottom"
|
|
55
|
+
props?: Record<string, unknown>; // passthrough to FieldInput
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Props
|
|
60
|
+
|
|
61
|
+
| Prop | Type | Default | Description |
|
|
62
|
+
| -------------------- | ------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
63
|
+
| `formData` | `ContactFormData` | empty | Bindable form data. |
|
|
64
|
+
| `onSubmit` | `(data: ContactFormData, botCheck: ContactBotCheck) => void` | required | Called after client-side validation passes. `botCheck` is report-only. |
|
|
65
|
+
| `isSubmitting` | `boolean` | `false` | Disables the CTA during submission. |
|
|
66
|
+
| `errors` | `ContactFormValidationError[]` | `[]` | Field-specific server errors (merged with internal validation). |
|
|
67
|
+
| `error` | `string` | - | General error rendered as a `DismissibleMessage` above the form. |
|
|
68
|
+
| `showName` | `boolean` | `false` | Render the Name field. |
|
|
69
|
+
| `requireName` | `boolean` | `true` | Require Name (only applies when shown). |
|
|
70
|
+
| `showPhone` | `boolean` | `false` | Render the Phone field. |
|
|
71
|
+
| `requirePhone` | `boolean` | `false` | Require Phone (only applies when shown). |
|
|
72
|
+
| `showSubject` | `boolean` | `false` | Render the Subject field (free text). |
|
|
73
|
+
| `requireSubject` | `boolean` | `false` | Require Subject (only applies when shown). |
|
|
74
|
+
| `subjectValues` | `string[]` | - | If set, Subject renders as a `<select>` of these values (and is auto-shown). A blank "Select…" prompt is prepended. |
|
|
75
|
+
| `showCompany` | `boolean` | `false` | Render the Company field. |
|
|
76
|
+
| `requireCompany` | `boolean` | `false` | Require Company (only applies when shown). |
|
|
77
|
+
| `messageMinLength` | `number` | `0` | Minimum message length. `0` disables the check. |
|
|
78
|
+
| `extraFields` | `ContactFieldConfig[]` | `[]` | Declarative extra fields, positioned top or bottom. |
|
|
79
|
+
| `extraFieldsSlot` | `Snippet<[{ formData, fieldError }]>` | - | Escape hatch for non-FieldInput extras (consent checkbox, captcha widget). |
|
|
80
|
+
| `useHoneypot` | `boolean` | `true` | Render the hidden honeypot trap. |
|
|
81
|
+
| `honeypotName` | `string` | `"link"` | Honeypot field name. Avoid browser-autofill tokens (`url`/`name`/`email`/…). |
|
|
82
|
+
| `useTimeTrap` | `boolean` | `true` | Render the time-trap. |
|
|
83
|
+
| `timeTrapMinMs` | `number` | `2000` | Minimum plausible human fill time (ms). |
|
|
84
|
+
| `submitLabel` | `string` | i18n | Override the CTA label. |
|
|
85
|
+
| `submittingLabel` | `string` | i18n | Override the CTA label while submitting. |
|
|
86
|
+
| `submitButton` | `Snippet<[{ isSubmitting, disabled }]>` | - | Override the entire CTA section. |
|
|
87
|
+
| `footer` | `Snippet` | - | Content below the form. |
|
|
88
|
+
| `notifications` | `NotificationsStack` | - | When set, general errors are also pushed via `notifications.error()`. |
|
|
89
|
+
| `t` | `TranslateFn` | English | i18n function. |
|
|
90
|
+
| `unstyled` / `class` | - | - | Standard styling escape hatches. |
|
|
91
|
+
| `el` | `HTMLFormElement` | - | Bindable form element. |
|
|
92
|
+
|
|
93
|
+
### Imperative methods (via `bind:this`)
|
|
94
|
+
|
|
95
|
+
| Method | Returns | Purpose |
|
|
96
|
+
| --------------------------- | --------- | ------------------------------------------------------------------- |
|
|
97
|
+
| `validate()` | `boolean` | Forces every visible field's validator to run. `true` if all valid. |
|
|
98
|
+
| `scrollToFirstError(opts?)` | `boolean` | Scrolls + focuses the first invalid field. Call after `validate()`. |
|
|
99
|
+
|
|
100
|
+
## Usage
|
|
101
|
+
|
|
102
|
+
### Basic (Email + Message only)
|
|
103
|
+
|
|
104
|
+
```svelte
|
|
105
|
+
<script lang="ts">
|
|
106
|
+
import { ContactUsForm } from "@marianmeres/stuic";
|
|
107
|
+
|
|
108
|
+
async function send(data, botCheck) {
|
|
109
|
+
// Enforce the bot signals on YOUR server — the form never blocks.
|
|
110
|
+
await fetch("/api/contact", {
|
|
111
|
+
method: "POST",
|
|
112
|
+
body: JSON.stringify({ ...data, botCheck }),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
</script>
|
|
116
|
+
|
|
117
|
+
<ContactUsForm onSubmit={send} />
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Standard layout (Name + Subject)
|
|
121
|
+
|
|
122
|
+
```svelte
|
|
123
|
+
<ContactUsForm onSubmit={send} showName showSubject requireSubject />
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Subject as a dropdown
|
|
127
|
+
|
|
128
|
+
Pass `subjectValues` to render the Subject as a `<select>` (the field is shown
|
|
129
|
+
automatically; the bound value is still the chosen string in `formData.subject`).
|
|
130
|
+
|
|
131
|
+
```svelte
|
|
132
|
+
<ContactUsForm
|
|
133
|
+
onSubmit={send}
|
|
134
|
+
requireSubject
|
|
135
|
+
subjectValues={["General enquiry", "Sales", "Support", "Feedback"]}
|
|
136
|
+
/>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Reacting to the bot signals
|
|
140
|
+
|
|
141
|
+
```svelte
|
|
142
|
+
<script lang="ts">
|
|
143
|
+
import { ContactUsForm } from "@marianmeres/stuic";
|
|
144
|
+
|
|
145
|
+
function onSubmit(data, botCheck) {
|
|
146
|
+
if (botCheck.isLikelyBot) {
|
|
147
|
+
// Silently accept on the client so bots don't learn; drop on the server.
|
|
148
|
+
console.warn("likely bot", botCheck);
|
|
149
|
+
}
|
|
150
|
+
send(data, botCheck);
|
|
151
|
+
}
|
|
152
|
+
</script>
|
|
153
|
+
|
|
154
|
+
<ContactUsForm {onSubmit} timeTrapMinMs={3000} />
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### With a consent checkbox (extraFieldsSlot)
|
|
158
|
+
|
|
159
|
+
```svelte
|
|
160
|
+
<script lang="ts">
|
|
161
|
+
import { ContactUsForm, FieldCheckbox } from "@marianmeres/stuic";
|
|
162
|
+
|
|
163
|
+
let formData = $state({
|
|
164
|
+
name: "",
|
|
165
|
+
email: "",
|
|
166
|
+
phone: "",
|
|
167
|
+
subject: "",
|
|
168
|
+
company: "",
|
|
169
|
+
message: "",
|
|
170
|
+
extra: { consent: false },
|
|
171
|
+
});
|
|
172
|
+
</script>
|
|
173
|
+
|
|
174
|
+
<ContactUsForm bind:formData onSubmit={send}>
|
|
175
|
+
{#snippet extraFieldsSlot({ formData, fieldError })}
|
|
176
|
+
<FieldCheckbox
|
|
177
|
+
bind:checked={formData.extra.consent}
|
|
178
|
+
label="I agree to be contacted"
|
|
179
|
+
error={fieldError("consent")}
|
|
180
|
+
/>
|
|
181
|
+
{/snippet}
|
|
182
|
+
</ContactUsForm>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Declarative extra field
|
|
186
|
+
|
|
187
|
+
```svelte
|
|
188
|
+
<ContactUsForm
|
|
189
|
+
onSubmit={send}
|
|
190
|
+
extraFields={[
|
|
191
|
+
{ name: "orderId", label: "Order number", position: "top", autocomplete: "off" },
|
|
192
|
+
]}
|
|
193
|
+
/>
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## CSS Variables
|
|
197
|
+
|
|
198
|
+
Prefix: `--stuic-contact-us-form-*`
|
|
199
|
+
|
|
200
|
+
| Variable | Purpose |
|
|
201
|
+
| ----------------------------- | ----------------------------- |
|
|
202
|
+
| `--stuic-contact-us-form-gap` | Vertical gap between sections |
|
|
203
|
+
|
|
204
|
+
## i18n keys
|
|
205
|
+
|
|
206
|
+
Under the `contact_form.*` namespace. See `_internal/contact-form-i18n-defaults.ts` for the full list and English defaults.
|
|
207
|
+
|
|
208
|
+
## See also
|
|
209
|
+
|
|
210
|
+
- [Input](../Input/README.md) — the `Field*` primitives this form is built from, including the `Honeypot` and `TimeTrap` anti-bot primitives.
|
|
211
|
+
- [RegisterForm](../RegisterForm/README.md) — same form conventions (declarative extras, `extraFieldsSlot`, imperative API).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function t_default(k: string, values?: false | null | undefined | Record<string, string | number>, fallback?: string | boolean): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { isPlainObject } from "../../../utils/is-plain-object.js";
|
|
2
|
+
import { replaceMap } from "../../../utils/replace-map.js";
|
|
3
|
+
const DEFAULTS = {
|
|
4
|
+
"contact_form.name_label": "Name",
|
|
5
|
+
"contact_form.name_placeholder": "",
|
|
6
|
+
"contact_form.email_label": "Email",
|
|
7
|
+
"contact_form.email_placeholder": "you@example.com",
|
|
8
|
+
"contact_form.phone_label": "Phone",
|
|
9
|
+
"contact_form.phone_placeholder": "",
|
|
10
|
+
"contact_form.company_label": "Company",
|
|
11
|
+
"contact_form.company_placeholder": "",
|
|
12
|
+
"contact_form.subject_label": "Subject",
|
|
13
|
+
"contact_form.subject_placeholder": "",
|
|
14
|
+
"contact_form.subject_select_prompt": "Select…",
|
|
15
|
+
"contact_form.message_label": "Message",
|
|
16
|
+
"contact_form.message_placeholder": "How can we help?",
|
|
17
|
+
"contact_form.submit": "Send message",
|
|
18
|
+
"contact_form.submitting": "Sending...",
|
|
19
|
+
"contact_form.name_required": "Name is required",
|
|
20
|
+
"contact_form.email_required": "Email is required",
|
|
21
|
+
"contact_form.email_invalid": "Please enter a valid email address",
|
|
22
|
+
"contact_form.phone_required": "Phone is required",
|
|
23
|
+
"contact_form.subject_required": "Subject is required",
|
|
24
|
+
"contact_form.company_required": "Company is required",
|
|
25
|
+
"contact_form.message_required": "Message is required",
|
|
26
|
+
"contact_form.message_too_short": "Message must be at least {min} characters",
|
|
27
|
+
"contact_form.field_required": "{label} is required",
|
|
28
|
+
};
|
|
29
|
+
export function t_default(k, values = null, fallback = "") {
|
|
30
|
+
const out = DEFAULTS[k] ?? (typeof fallback === "string" ? fallback : "") ?? k;
|
|
31
|
+
return isPlainObject(values)
|
|
32
|
+
? replaceMap(out, values, {
|
|
33
|
+
preSearchKeyTransform: (k) => `{${k}}`,
|
|
34
|
+
})
|
|
35
|
+
: out;
|
|
36
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { HTMLInputAttributes } from "svelte/elements";
|
|
2
|
+
export interface ContactFormData {
|
|
3
|
+
name: string;
|
|
4
|
+
email: string;
|
|
5
|
+
phone: string;
|
|
6
|
+
subject: string;
|
|
7
|
+
company: string;
|
|
8
|
+
message: string;
|
|
9
|
+
/** Values for consumer-defined extraFields, keyed by field name. */
|
|
10
|
+
extra: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
export interface ContactFormValidationError {
|
|
13
|
+
field: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Anti-bot signals surfaced as the 2nd argument to `onSubmit`.
|
|
18
|
+
*
|
|
19
|
+
* Report-only: ContactUsForm never blocks a submit on these — enforce them
|
|
20
|
+
* server-side (drop / rate-limit / queue-for-review). `isLikelyBot` is a
|
|
21
|
+
* convenience OR of the individual signals.
|
|
22
|
+
*/
|
|
23
|
+
export interface ContactBotCheck {
|
|
24
|
+
/** Raw honeypot value. A non-empty value means a bot filled the hidden trap. */
|
|
25
|
+
honeypot: string;
|
|
26
|
+
/** `true` when the honeypot was filled. */
|
|
27
|
+
honeypotFilled: boolean;
|
|
28
|
+
/** Milliseconds between form mount and submit. */
|
|
29
|
+
elapsedMs: number;
|
|
30
|
+
/** `true` when `elapsedMs < minMs` (submitted suspiciously fast). */
|
|
31
|
+
isTooFast: boolean;
|
|
32
|
+
/** The configured minimum fill time (ms). */
|
|
33
|
+
minMs: number;
|
|
34
|
+
/** `honeypotFilled || isTooFast`. */
|
|
35
|
+
isLikelyBot: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** Declarative descriptor for a consumer-defined FieldInput-style extra field. */
|
|
38
|
+
export interface ContactFieldConfig {
|
|
39
|
+
/** Key under `formData.extra` where the value lives. Must be unique. */
|
|
40
|
+
name: string;
|
|
41
|
+
/** Visible label (already translated; ContactUsForm does not apply `t()` here). */
|
|
42
|
+
label: string;
|
|
43
|
+
/** FieldInput `type`. Default "text". */
|
|
44
|
+
type?: "text" | "email" | "tel" | "url" | "number";
|
|
45
|
+
placeholder?: string;
|
|
46
|
+
required?: boolean;
|
|
47
|
+
autocomplete?: HTMLInputAttributes["autocomplete"];
|
|
48
|
+
/** Initial value to seed into `formData.extra[name]` if undefined. */
|
|
49
|
+
initialValue?: unknown;
|
|
50
|
+
/**
|
|
51
|
+
* Synchronous validator. Return empty string / undefined for "valid".
|
|
52
|
+
* Return a message string for "invalid" (wired into the same error pipeline).
|
|
53
|
+
*/
|
|
54
|
+
validate?: (value: unknown, data: ContactFormData) => string | undefined;
|
|
55
|
+
/** Render before the message field ("top") or after it ("bottom"). Default "bottom". */
|
|
56
|
+
position?: "top" | "bottom";
|
|
57
|
+
/** Extra passthrough props merged onto FieldInput. */
|
|
58
|
+
props?: Record<string, unknown>;
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ContactFieldConfig, ContactFormData, ContactFormValidationError } from "./contact-form-types.js";
|
|
2
|
+
import type { TranslateFn } from "../../../types.js";
|
|
3
|
+
export interface ValidateContactFormOptions {
|
|
4
|
+
showName?: boolean;
|
|
5
|
+
requireName?: boolean;
|
|
6
|
+
showPhone?: boolean;
|
|
7
|
+
requirePhone?: boolean;
|
|
8
|
+
showSubject?: boolean;
|
|
9
|
+
requireSubject?: boolean;
|
|
10
|
+
showCompany?: boolean;
|
|
11
|
+
requireCompany?: boolean;
|
|
12
|
+
/** Minimum message length. 0 (default) disables the check. */
|
|
13
|
+
messageMinLength?: number;
|
|
14
|
+
extraFields?: ContactFieldConfig[];
|
|
15
|
+
}
|
|
16
|
+
export declare function validateContactForm(data: ContactFormData, t: TranslateFn, options?: ValidateContactFormOptions): ContactFormValidationError[];
|
|
17
|
+
export declare function createEmptyContactFormData(): ContactFormData;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
2
|
+
export function validateContactForm(data, t, options = {}) {
|
|
3
|
+
const { showName = false, requireName = true, showPhone = false, requirePhone = false, showSubject = false, requireSubject = false, showCompany = false, requireCompany = false, messageMinLength = 0, extraFields = [], } = options;
|
|
4
|
+
const errors = [];
|
|
5
|
+
// Email — always present, always validated.
|
|
6
|
+
const trimmedEmail = data.email.trim();
|
|
7
|
+
if (!trimmedEmail) {
|
|
8
|
+
errors.push({ field: "email", message: t("contact_form.email_required") });
|
|
9
|
+
}
|
|
10
|
+
else if (!EMAIL_RE.test(trimmedEmail)) {
|
|
11
|
+
errors.push({ field: "email", message: t("contact_form.email_invalid") });
|
|
12
|
+
}
|
|
13
|
+
// Message — always present, always required.
|
|
14
|
+
const trimmedMessage = data.message.trim();
|
|
15
|
+
if (!trimmedMessage) {
|
|
16
|
+
errors.push({ field: "message", message: t("contact_form.message_required") });
|
|
17
|
+
}
|
|
18
|
+
else if (messageMinLength > 0 && trimmedMessage.length < messageMinLength) {
|
|
19
|
+
errors.push({
|
|
20
|
+
field: "message",
|
|
21
|
+
message: t("contact_form.message_too_short", { min: messageMinLength }),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
// Optional toggled fields — only validated when shown AND required.
|
|
25
|
+
if (showName && requireName && !data.name.trim()) {
|
|
26
|
+
errors.push({ field: "name", message: t("contact_form.name_required") });
|
|
27
|
+
}
|
|
28
|
+
if (showPhone && requirePhone && !data.phone.trim()) {
|
|
29
|
+
errors.push({ field: "phone", message: t("contact_form.phone_required") });
|
|
30
|
+
}
|
|
31
|
+
if (showSubject && requireSubject && !data.subject.trim()) {
|
|
32
|
+
errors.push({ field: "subject", message: t("contact_form.subject_required") });
|
|
33
|
+
}
|
|
34
|
+
if (showCompany && requireCompany && !data.company.trim()) {
|
|
35
|
+
errors.push({ field: "company", message: t("contact_form.company_required") });
|
|
36
|
+
}
|
|
37
|
+
// Consumer-defined extra fields.
|
|
38
|
+
for (const cfg of extraFields) {
|
|
39
|
+
const value = data.extra?.[cfg.name];
|
|
40
|
+
const asString = typeof value === "string" ? value : value == null ? "" : String(value);
|
|
41
|
+
if (cfg.required && !asString.trim()) {
|
|
42
|
+
errors.push({
|
|
43
|
+
field: cfg.name,
|
|
44
|
+
message: t("contact_form.field_required", { label: cfg.label }),
|
|
45
|
+
});
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (cfg.validate) {
|
|
49
|
+
const msg = cfg.validate(value, data);
|
|
50
|
+
if (msg)
|
|
51
|
+
errors.push({ field: cfg.name, message: msg });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return errors;
|
|
55
|
+
}
|
|
56
|
+
export function createEmptyContactFormData() {
|
|
57
|
+
return {
|
|
58
|
+
name: "",
|
|
59
|
+
email: "",
|
|
60
|
+
phone: "",
|
|
61
|
+
subject: "",
|
|
62
|
+
company: "",
|
|
63
|
+
message: "",
|
|
64
|
+
extra: {},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
/* ContactUsForm */
|
|
3
|
+
--stuic-contact-us-form-gap: 0rem;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
@layer components {
|
|
7
|
+
.stuic-contact-us-form {
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
gap: var(--stuic-contact-us-form-gap);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.stuic-contact-us-form-submit {
|
|
14
|
+
margin-top: 1.5rem;
|
|
15
|
+
margin-bottom: 1.5rem;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Tighten default FieldInput bottom margin (mb-8 → 1rem) inside the form */
|
|
20
|
+
.stuic-contact-us-form .stuic-input {
|
|
21
|
+
margin-bottom: 1rem;
|
|
22
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { default as ContactUsForm, type Props as ContactUsFormProps, } from "./ContactUsForm.svelte";
|
|
2
|
+
export { createEmptyContactFormData } from "./_internal/contact-form-utils.js";
|
|
3
|
+
export type { ValidateContactFormOptions } from "./_internal/contact-form-utils.js";
|
|
4
|
+
export type { ContactFormData, ContactFormValidationError, ContactBotCheck, ContactFieldConfig, } from "./_internal/contact-form-types.js";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { HTMLInputAttributes } from "svelte/elements";
|
|
3
|
+
|
|
4
|
+
export interface Props extends Omit<HTMLInputAttributes, "value" | "name"> {
|
|
5
|
+
/**
|
|
6
|
+
* Current trap value. Real users never see the field, so a non-empty value
|
|
7
|
+
* is a strong "this was filled by a bot" signal. Bindable.
|
|
8
|
+
*/
|
|
9
|
+
value?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Name (and id) of the trap field. Pick something a naive bot finds
|
|
12
|
+
* tempting to fill, that your real form does NOT use, and — importantly —
|
|
13
|
+
* that browser/profile autofill does NOT recognize as a known field.
|
|
14
|
+
* Avoid `url`/`website`/`email`/`name`/`phone`/`address`/`company`: those
|
|
15
|
+
* are exactly what Chrome/Safari autofill writes into, which would fill the
|
|
16
|
+
* off-screen trap for a real user and create a false positive. Default:
|
|
17
|
+
* "link" (tempting to link-spam bots, ignored by autofill).
|
|
18
|
+
*/
|
|
19
|
+
name?: string;
|
|
20
|
+
id?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Screen-reader-only instruction. The wrapper is `aria-hidden`, so this is
|
|
23
|
+
* a belt-and-suspenders fallback for the rare AT that ignores aria-hidden.
|
|
24
|
+
* Default: "Leave this field empty".
|
|
25
|
+
*/
|
|
26
|
+
label?: string;
|
|
27
|
+
/** Wrapper element ref. Bindable. */
|
|
28
|
+
el?: HTMLDivElement;
|
|
29
|
+
/** Underlying `<input>` ref. Bindable. */
|
|
30
|
+
input?: HTMLInputElement;
|
|
31
|
+
/**
|
|
32
|
+
* Drop the `stuic-honeypot` class. The hiding styles are applied inline and
|
|
33
|
+
* are NOT affected by this — a honeypot must stay hidden to function.
|
|
34
|
+
*/
|
|
35
|
+
unstyled?: boolean;
|
|
36
|
+
class?: string;
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<script lang="ts">
|
|
41
|
+
import { getId } from "../../utils/get-id.js";
|
|
42
|
+
import { twMerge } from "../../utils/tw-merge.js";
|
|
43
|
+
|
|
44
|
+
let {
|
|
45
|
+
value = $bindable(""),
|
|
46
|
+
name = "link",
|
|
47
|
+
id = getId(),
|
|
48
|
+
label = "Leave this field empty",
|
|
49
|
+
el = $bindable(),
|
|
50
|
+
input = $bindable(),
|
|
51
|
+
unstyled = false,
|
|
52
|
+
class: classProp,
|
|
53
|
+
...rest
|
|
54
|
+
}: Props = $props();
|
|
55
|
+
|
|
56
|
+
/** `true` when the trap was filled (i.e. the submitter is likely a bot). */
|
|
57
|
+
export function isFilled(): boolean {
|
|
58
|
+
return (value ?? "").trim() !== "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Hiding here is FUNCTIONAL, not cosmetic: a visible honeypot would be filled
|
|
62
|
+
// by real users and produce false positives. We inline the hide styles (rather
|
|
63
|
+
// than rely on a CSS class) so the trap stays hidden even if the consumer has
|
|
64
|
+
// not imported the library stylesheet — the primitive is self-contained. This
|
|
65
|
+
// is the standard visually-hidden recipe, kept off-screen rather than
|
|
66
|
+
// `display:none`/`type=hidden` (which many bots skip).
|
|
67
|
+
const HIDE_STYLE =
|
|
68
|
+
"position:absolute !important;width:1px;height:1px;padding:0;margin:-1px;" +
|
|
69
|
+
"overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;";
|
|
70
|
+
|
|
71
|
+
let _class = $derived(unstyled ? classProp : twMerge("stuic-honeypot", classProp));
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<!--
|
|
75
|
+
aria-hidden + tabindex=-1 keep the trap out of the accessibility tree and tab
|
|
76
|
+
order, so assistive tech and keyboard users never reach it. autocomplete is set
|
|
77
|
+
to "new-password" — NOT "off", which Chrome/Safari ignore on non-credential text
|
|
78
|
+
fields — because "new-password" is the value browsers and password managers
|
|
79
|
+
actually honor as do-not-autofill, so autofill won't write into the trap and
|
|
80
|
+
falsely flag a real user.
|
|
81
|
+
-->
|
|
82
|
+
<div bind:this={el} class={_class} style={HIDE_STYLE} aria-hidden="true">
|
|
83
|
+
<label for={id}>{label}</label>
|
|
84
|
+
<input
|
|
85
|
+
bind:this={input}
|
|
86
|
+
bind:value
|
|
87
|
+
{id}
|
|
88
|
+
{name}
|
|
89
|
+
type="text"
|
|
90
|
+
tabindex={-1}
|
|
91
|
+
autocomplete="new-password"
|
|
92
|
+
{...rest}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { HTMLInputAttributes } from "svelte/elements";
|
|
2
|
+
export interface Props extends Omit<HTMLInputAttributes, "value" | "name"> {
|
|
3
|
+
/**
|
|
4
|
+
* Current trap value. Real users never see the field, so a non-empty value
|
|
5
|
+
* is a strong "this was filled by a bot" signal. Bindable.
|
|
6
|
+
*/
|
|
7
|
+
value?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Name (and id) of the trap field. Pick something a naive bot finds
|
|
10
|
+
* tempting to fill, that your real form does NOT use, and — importantly —
|
|
11
|
+
* that browser/profile autofill does NOT recognize as a known field.
|
|
12
|
+
* Avoid `url`/`website`/`email`/`name`/`phone`/`address`/`company`: those
|
|
13
|
+
* are exactly what Chrome/Safari autofill writes into, which would fill the
|
|
14
|
+
* off-screen trap for a real user and create a false positive. Default:
|
|
15
|
+
* "link" (tempting to link-spam bots, ignored by autofill).
|
|
16
|
+
*/
|
|
17
|
+
name?: string;
|
|
18
|
+
id?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Screen-reader-only instruction. The wrapper is `aria-hidden`, so this is
|
|
21
|
+
* a belt-and-suspenders fallback for the rare AT that ignores aria-hidden.
|
|
22
|
+
* Default: "Leave this field empty".
|
|
23
|
+
*/
|
|
24
|
+
label?: string;
|
|
25
|
+
/** Wrapper element ref. Bindable. */
|
|
26
|
+
el?: HTMLDivElement;
|
|
27
|
+
/** Underlying `<input>` ref. Bindable. */
|
|
28
|
+
input?: HTMLInputElement;
|
|
29
|
+
/**
|
|
30
|
+
* Drop the `stuic-honeypot` class. The hiding styles are applied inline and
|
|
31
|
+
* are NOT affected by this — a honeypot must stay hidden to function.
|
|
32
|
+
*/
|
|
33
|
+
unstyled?: boolean;
|
|
34
|
+
class?: string;
|
|
35
|
+
}
|
|
36
|
+
declare const Honeypot: import("svelte").Component<Props, {
|
|
37
|
+
isFilled: () => boolean;
|
|
38
|
+
}, "el" | "value" | "input">;
|
|
39
|
+
type Honeypot = ReturnType<typeof Honeypot>;
|
|
40
|
+
export default Honeypot;
|
|
@@ -18,6 +18,8 @@ A comprehensive form input system with multiple field components, validation sup
|
|
|
18
18
|
| `FieldKeyValues` | Key-value pairs editor with JSON serialization |
|
|
19
19
|
| `FieldLikeButton` | Like/favorite toggle button |
|
|
20
20
|
| `Fieldset` | Fieldset with legend |
|
|
21
|
+
| `Honeypot` | Hidden anti-bot trap field (server-less) |
|
|
22
|
+
| `TimeTrap` | Anti-bot submit-timing primitive (server-less) |
|
|
21
23
|
|
|
22
24
|
## Common Props (FieldInput, FieldTextarea, FieldSelect)
|
|
23
25
|
|
|
@@ -439,3 +441,70 @@ A modal-based multi-select/single-select component with search functionality, ty
|
|
|
439
441
|
<!-- Local customization -->
|
|
440
442
|
<FieldOptions style="--stuic-field-options-divider: var(--color-red-500);" ... />
|
|
441
443
|
```
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
## Honeypot & TimeTrap (anti-bot primitives)
|
|
448
|
+
|
|
449
|
+
Two small, **client-side, server-less** primitives for cheap spam mitigation. They produce **signals** — they do not block anything. Read the signal, then enforce on your server (the only place enforcement is trustworthy). Both are reusable on any form; [`ContactUsForm`](../ContactUsForm/README.md) composes them by default.
|
|
450
|
+
|
|
451
|
+
> These stop a large share of commodity drive-by spam (no UX friction, no external dependency, no privacy/GDPR concern). They do **not** stop a targeted or headless-browser bot — for that you still want a real challenge (Cloudflare Turnstile / hCaptcha) verified server-side.
|
|
452
|
+
|
|
453
|
+
### `Honeypot`
|
|
454
|
+
|
|
455
|
+
A visually-hidden, `aria-hidden`, off-screen text input that real users and assistive tech never reach, but naive bots fill. A non-empty value is a strong bot signal. The hiding styles are applied **inline** so the trap stays hidden even without the library stylesheet. The input uses `autocomplete="new-password"` (the value browsers actually honor as do-not-autofill — `autocomplete="off"` is ignored on text fields) so browser/profile autofill won't write into the trap and flag a real user.
|
|
456
|
+
|
|
457
|
+
```svelte
|
|
458
|
+
<script lang="ts">
|
|
459
|
+
import { Honeypot } from "@marianmeres/stuic";
|
|
460
|
+
|
|
461
|
+
let trap = $state("");
|
|
462
|
+
// On submit: if trap.trim() !== "" treat as a bot (server-side).
|
|
463
|
+
</script>
|
|
464
|
+
|
|
465
|
+
<form onsubmit={...}>
|
|
466
|
+
<!-- ...your real fields... -->
|
|
467
|
+
<Honeypot bind:value={trap} />
|
|
468
|
+
</form>
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
| Prop | Type | Default | Description |
|
|
472
|
+
| -------------------- | ------------- | -------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
473
|
+
| `value` | `string` | `""` | Trap value (bindable). Non-empty ⇒ likely a bot. |
|
|
474
|
+
| `name` | `string` | `"link"` | Field name. Default avoids autofill tokens (`url`/`name`/`email`/…). Pick one your real form doesn't use. |
|
|
475
|
+
| `label` | `string` | `"Leave this field empty"` | Screen-reader fallback (wrapper is `aria-hidden`). |
|
|
476
|
+
| `el` / `input` | `HTMLElement` | - | Bindable wrapper / input refs. |
|
|
477
|
+
| `unstyled` / `class` | - | - | Drop / extend the `stuic-honeypot` class (hiding is always applied). |
|
|
478
|
+
|
|
479
|
+
**Method:** `isFilled(): boolean` (via `bind:this`).
|
|
480
|
+
|
|
481
|
+
> **Naming caveat:** keep the field name tempting to bots but **not** a token browser autofill recognizes. Names like `url`/`website`/`email`/`name`/`phone`/`address` get autofilled into the off-screen trap from the user's saved profile, producing false positives. The default `"link"` and `autocomplete="new-password"` avoid this.
|
|
482
|
+
|
|
483
|
+
### `TimeTrap`
|
|
484
|
+
|
|
485
|
+
Records form mount time and flags submits that arrive faster than `minMs`. Renders a single hidden timestamp input (handy for native form POSTs) and exposes reactive `isTooFast` / `elapsedMs` bindings plus a `check()` snapshot for exact submit-time reads.
|
|
486
|
+
|
|
487
|
+
```svelte
|
|
488
|
+
<script lang="ts">
|
|
489
|
+
import { TimeTrap } from "@marianmeres/stuic";
|
|
490
|
+
|
|
491
|
+
let tooFast = $state(true);
|
|
492
|
+
let trap = $state<TimeTrap>();
|
|
493
|
+
// On submit: const { elapsedMs, isTooFast } = trap.check();
|
|
494
|
+
</script>
|
|
495
|
+
|
|
496
|
+
<TimeTrap bind:this={trap} bind:isTooFast={tooFast} minMs={2000} />
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
| Prop | Type | Default | Description |
|
|
500
|
+
| ----------- | --------- | ------- | --------------------------------------------------------------- |
|
|
501
|
+
| `minMs` | `number` | `2000` | Minimum plausible human fill time (ms). |
|
|
502
|
+
| `enabled` | `boolean` | `true` | When false, `isTooFast` stays `false` and no timer runs. |
|
|
503
|
+
| `isTooFast` | `boolean` | `true` | Bindable, reactive; `true` until `minMs` elapses. |
|
|
504
|
+
| `elapsedMs` | `number` | `0` | Bindable; updated on the flip and on `check()`. |
|
|
505
|
+
| `startedAt` | `number` | - | Bindable; epoch ms captured at mount; also in the hidden input. |
|
|
506
|
+
| `name` | `string` | `"_ts"` | Name of the rendered hidden timestamp input. |
|
|
507
|
+
|
|
508
|
+
**Methods:** `check(): { elapsedMs, isTooFast, startedAt }`, `reset()` (via `bind:this`).
|
|
509
|
+
|
|
510
|
+
> The timestamp is a client clock and is **not** tamper-proof. Treat `isTooFast` as a heuristic and pair it with real server-side rate limiting.
|