@marianmeres/stuic 3.126.1 → 3.128.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.
@@ -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,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,2 @@
1
+ export { default as ContactUsForm, } from "./ContactUsForm.svelte";
2
+ export { createEmptyContactFormData } from "./_internal/contact-form-utils.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.