@marianmeres/stuic 3.157.0 → 3.159.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/Checkout/CheckoutGuestForm.svelte +124 -5
- package/dist/components/Checkout/CheckoutGuestOrLoginForm.svelte +4 -0
- package/dist/components/Checkout/CheckoutGuestOrLoginForm.svelte.d.ts +1 -1
- package/dist/components/ContactUsForm/ContactUsForm.svelte +107 -10
- package/dist/components/ContactUsForm/README.md +16 -1
- package/dist/components/ContactUsForm/_internal/contact-form-types.d.ts +6 -1
- package/dist/components/LoginForm/LoginForm.svelte +40 -5
- package/dist/components/LoginForm/README.md +34 -19
- package/dist/components/LoginOrRegisterForm/LoginOrRegisterForm.svelte +73 -16
- package/dist/components/LoginOrRegisterForm/LoginOrRegisterForm.svelte.d.ts +12 -1
- package/dist/components/LoginOrRegisterForm/LoginOrRegisterFormModal.svelte +32 -1
- package/dist/components/LoginOrRegisterForm/LoginOrRegisterFormModal.svelte.d.ts +7 -1
- package/dist/components/LoginOrRegisterForm/README.md +45 -29
- package/dist/components/LoginOrRegisterForm/_internal/login-or-register-form-i18n-defaults.js +1 -0
- package/dist/components/LoginOrRegisterForm/index.css +18 -0
- package/dist/components/RegisterForm/README.md +177 -37
- package/dist/components/RegisterForm/RegisterForm.svelte +329 -76
- package/dist/components/RegisterForm/RegisterForm.svelte.d.ts +77 -3
- package/dist/components/RegisterForm/RegisterFormModal.svelte +78 -0
- package/dist/components/RegisterForm/RegisterFormModal.svelte.d.ts +31 -0
- package/dist/components/RegisterForm/_internal/register-form-i18n-defaults.js +1 -0
- package/dist/components/RegisterForm/_internal/register-form-types.d.ts +12 -2
- package/dist/components/RegisterForm/_internal/register-form-utils.d.ts +19 -2
- package/dist/components/RegisterForm/_internal/register-form-utils.js +34 -17
- package/dist/components/RegisterForm/index.css +33 -2
- package/dist/components/RegisterForm/index.d.ts +2 -1
- package/dist/components/RegisterForm/index.js +1 -1
- package/dist/utils/field-errors.svelte.d.ts +114 -0
- package/dist/utils/field-errors.svelte.js +131 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/docs/domains/components.md +54 -38
- package/docs/domains/utils.md +57 -0
- package/package.json +2 -2
- package/dist/components/Input/node_modules/.vite/vitest/d2a04d71301a8915217dd5faf81d12cffd6cd958/_svelte_metadata.json +0 -1
- package/dist/components/Input/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/_svelte_metadata.json +0 -1
|
@@ -70,7 +70,12 @@
|
|
|
70
70
|
</script>
|
|
71
71
|
|
|
72
72
|
<script lang="ts">
|
|
73
|
+
import { untrack } from "svelte";
|
|
73
74
|
import { twMerge } from "../../utils/tw-merge.js";
|
|
75
|
+
import {
|
|
76
|
+
createExternalFieldErrors,
|
|
77
|
+
repaintFieldErrors,
|
|
78
|
+
} from "../../utils/field-errors.svelte.js";
|
|
74
79
|
import {
|
|
75
80
|
scrollToFirstInvalidField,
|
|
76
81
|
validateAllFields,
|
|
@@ -83,6 +88,7 @@
|
|
|
83
88
|
import Button from "../Button/Button.svelte";
|
|
84
89
|
import FieldInput from "../Input/FieldInput.svelte";
|
|
85
90
|
import FieldPhoneNumber from "../Input/FieldPhoneNumber.svelte";
|
|
91
|
+
import { validatePhoneNumber } from "../Input/phone-validation.js";
|
|
86
92
|
|
|
87
93
|
let {
|
|
88
94
|
formData = $bindable(createEmptyCustomerFormData()),
|
|
@@ -110,11 +116,58 @@
|
|
|
110
116
|
// Internal validation errors (set on submit)
|
|
111
117
|
let internalErrors = $state<CheckoutValidationError[]>([]);
|
|
112
118
|
|
|
119
|
+
/** Is this field currently rendered? (B2B block + per-field `fields` opt-outs) */
|
|
120
|
+
function _isRendered(field: string): boolean {
|
|
121
|
+
switch (field) {
|
|
122
|
+
case "email":
|
|
123
|
+
return true;
|
|
124
|
+
case "first_name":
|
|
125
|
+
case "last_name":
|
|
126
|
+
case "phone":
|
|
127
|
+
return fields?.[field] !== false;
|
|
128
|
+
case "company_name":
|
|
129
|
+
case "tax_id":
|
|
130
|
+
case "vat_number":
|
|
131
|
+
return showB2bFields && fields?.[field] !== false;
|
|
132
|
+
default:
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Clear internal field errors as soon as the user edits the form, so a previous
|
|
138
|
+
// failed submit's errors don't linger after the user has fixed them — matching
|
|
139
|
+
// the other STUIC forms. `untrack` the read+write so this effect re-runs only
|
|
140
|
+
// on formData changes, not when handleSubmit sets internalErrors.
|
|
141
|
+
$effect(() => {
|
|
142
|
+
void formData.email;
|
|
143
|
+
void formData.first_name;
|
|
144
|
+
void formData.last_name;
|
|
145
|
+
void formData.phone;
|
|
146
|
+
void formData.company_name;
|
|
147
|
+
void formData.tax_id;
|
|
148
|
+
void formData.vat_number;
|
|
149
|
+
untrack(() => {
|
|
150
|
+
if (internalErrors.length) internalErrors = [];
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Give the consumer-owned `errors` prop a lifecycle: an entry for a field this
|
|
155
|
+
// form renders self-clears once the user edits it, instead of blocking submit
|
|
156
|
+
// forever through the gate below (the consumer's own handler, which would have
|
|
157
|
+
// cleared the errors, is exactly what was being suppressed). Entries for
|
|
158
|
+
// anything else keep blocking until the consumer drops them.
|
|
159
|
+
const external = createExternalFieldErrors({
|
|
160
|
+
errors: () => externalErrors,
|
|
161
|
+
isRendered: _isRendered,
|
|
162
|
+
valueOf: (field) =>
|
|
163
|
+
(formData as unknown as Record<string, string | undefined>)[field] ?? "",
|
|
164
|
+
});
|
|
165
|
+
|
|
113
166
|
// Merge internal + external errors; external takes precedence per field
|
|
114
167
|
let allErrors = $derived.by(() => {
|
|
115
168
|
const map = new Map<string, string>();
|
|
116
169
|
for (const e of internalErrors) map.set(e.field, e.message);
|
|
117
|
-
for (const e of
|
|
170
|
+
for (const e of external.live) map.set(e.field, e.message);
|
|
118
171
|
return [...map.entries()].map(([field, message]) => ({ field, message }));
|
|
119
172
|
});
|
|
120
173
|
|
|
@@ -132,7 +185,8 @@
|
|
|
132
185
|
|
|
133
186
|
internalErrors = validationErrors;
|
|
134
187
|
|
|
135
|
-
if (validationErrors.length === 0 &&
|
|
188
|
+
if (validationErrors.length === 0 && external.live.length === 0) {
|
|
189
|
+
external.markSubmitted();
|
|
136
190
|
onSubmit(formData);
|
|
137
191
|
}
|
|
138
192
|
}
|
|
@@ -167,11 +221,41 @@
|
|
|
167
221
|
];
|
|
168
222
|
}
|
|
169
223
|
|
|
224
|
+
function _fieldByName(name: string) {
|
|
225
|
+
if (!_isRendered(name)) return undefined;
|
|
226
|
+
switch (name) {
|
|
227
|
+
case "email":
|
|
228
|
+
return emailField;
|
|
229
|
+
case "first_name":
|
|
230
|
+
return firstNameField;
|
|
231
|
+
case "last_name":
|
|
232
|
+
return lastNameField;
|
|
233
|
+
case "phone":
|
|
234
|
+
return phoneField;
|
|
235
|
+
case "company_name":
|
|
236
|
+
return companyNameField;
|
|
237
|
+
case "tax_id":
|
|
238
|
+
return taxIdField;
|
|
239
|
+
case "vat_number":
|
|
240
|
+
return vatNumberField;
|
|
241
|
+
default:
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Paint newly-arrived error messages without waiting for the user's next
|
|
247
|
+
// interaction — otherwise a failed validation (or a server `errors` delivery)
|
|
248
|
+
// blocked the submit with no message anywhere.
|
|
249
|
+
repaintFieldErrors(() => allErrors, _fieldByName);
|
|
250
|
+
|
|
170
251
|
/**
|
|
171
252
|
* Run every visible field's validator and render any inline errors.
|
|
172
253
|
* Returns true if all fields are valid.
|
|
173
254
|
*/
|
|
174
255
|
export function validate(): boolean {
|
|
256
|
+
// Consumers posting from their own handler never reach `handleSubmit`, so
|
|
257
|
+
// this has to arm the same "a round-trip is starting" flag.
|
|
258
|
+
external.markSubmitted();
|
|
175
259
|
return validateAllFields(_fields());
|
|
176
260
|
}
|
|
177
261
|
|
|
@@ -201,9 +285,11 @@
|
|
|
201
285
|
{...rest}
|
|
202
286
|
>
|
|
203
287
|
<!--
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
288
|
+
NOTE on `binding_property_non_reactive`: formData is a $bindable prop — deep
|
|
289
|
+
reactivity depends on the consumer passing a $state() object. The bindings
|
|
290
|
+
work correctly regardless; the per-field directives below silence the hint.
|
|
291
|
+
(This block intentionally does NOT start with the literal directive word so
|
|
292
|
+
it isn't parsed as one — every following word would become a bogus code.)
|
|
207
293
|
-->
|
|
208
294
|
<!-- Email (always shown, always required) -->
|
|
209
295
|
<!-- svelte-ignore binding_property_non_reactive -->
|
|
@@ -235,6 +321,11 @@
|
|
|
235
321
|
labelLeftBreakpoint={0}
|
|
236
322
|
placeholder={t("checkout.guest.first_name_placeholder")}
|
|
237
323
|
name="checkout-guest-first-name"
|
|
324
|
+
validate={{
|
|
325
|
+
customValidator() {
|
|
326
|
+
return fieldError("first_name") || "";
|
|
327
|
+
},
|
|
328
|
+
}}
|
|
238
329
|
/>
|
|
239
330
|
{/if}
|
|
240
331
|
{#if fields?.last_name !== false}
|
|
@@ -246,6 +337,11 @@
|
|
|
246
337
|
labelLeftBreakpoint={0}
|
|
247
338
|
placeholder={t("checkout.guest.last_name_placeholder")}
|
|
248
339
|
name="checkout-guest-last-name"
|
|
340
|
+
validate={{
|
|
341
|
+
customValidator() {
|
|
342
|
+
return fieldError("last_name") || "";
|
|
343
|
+
},
|
|
344
|
+
}}
|
|
249
345
|
/>
|
|
250
346
|
{/if}
|
|
251
347
|
</div>
|
|
@@ -261,6 +357,14 @@
|
|
|
261
357
|
placeholder={t("checkout.guest.phone_placeholder")}
|
|
262
358
|
name="checkout-guest-phone"
|
|
263
359
|
labelLeftBreakpoint={0}
|
|
360
|
+
validate={{
|
|
361
|
+
// FieldPhoneNumber's customValidator REPLACES its built-in
|
|
362
|
+
// `validatePhoneNumber`, so surface the server error and then
|
|
363
|
+
// delegate rather than knocking phone validation out.
|
|
364
|
+
customValidator(val, ctx, el) {
|
|
365
|
+
return fieldError("phone") || validatePhoneNumber(val, ctx, el) || "";
|
|
366
|
+
},
|
|
367
|
+
}}
|
|
264
368
|
{...phoneFieldProps}
|
|
265
369
|
/>
|
|
266
370
|
{/if}
|
|
@@ -280,6 +384,11 @@
|
|
|
280
384
|
label={t("checkout.guest.company_name_label")}
|
|
281
385
|
name="checkout-guest-company-name"
|
|
282
386
|
labelLeftBreakpoint={0}
|
|
387
|
+
validate={{
|
|
388
|
+
customValidator() {
|
|
389
|
+
return fieldError("company_name") || "";
|
|
390
|
+
},
|
|
391
|
+
}}
|
|
283
392
|
/>
|
|
284
393
|
{/if}
|
|
285
394
|
{#if fields?.tax_id !== false || fields?.vat_number !== false}
|
|
@@ -291,6 +400,11 @@
|
|
|
291
400
|
bind:value={formData.tax_id}
|
|
292
401
|
label={t("checkout.guest.tax_id_label")}
|
|
293
402
|
name="checkout-guest-tax-id"
|
|
403
|
+
validate={{
|
|
404
|
+
customValidator() {
|
|
405
|
+
return fieldError("tax_id") || "";
|
|
406
|
+
},
|
|
407
|
+
}}
|
|
294
408
|
/>
|
|
295
409
|
{/if}
|
|
296
410
|
{#if fields?.vat_number !== false}
|
|
@@ -300,6 +414,11 @@
|
|
|
300
414
|
bind:value={formData.vat_number}
|
|
301
415
|
label={t("checkout.guest.vat_number_label")}
|
|
302
416
|
name="checkout-guest-vat-number"
|
|
417
|
+
validate={{
|
|
418
|
+
customValidator() {
|
|
419
|
+
return fieldError("vat_number") || "";
|
|
420
|
+
},
|
|
421
|
+
}}
|
|
303
422
|
/>
|
|
304
423
|
{/if}
|
|
305
424
|
</div>
|
|
@@ -78,8 +78,10 @@
|
|
|
78
78
|
| "registerProps"
|
|
79
79
|
| "verifyProps"
|
|
80
80
|
| "socialLogins"
|
|
81
|
+
| "socialPosition"
|
|
81
82
|
| "socialDividerLabel"
|
|
82
83
|
| "footer"
|
|
84
|
+
| "animateHeight"
|
|
83
85
|
| "modeSwitcher"
|
|
84
86
|
| "loginModeLabel"
|
|
85
87
|
| "registerModeLabel"
|
|
@@ -445,7 +447,9 @@
|
|
|
445
447
|
registerModeLabel={loginOrRegisterModal.registerModeLabel}
|
|
446
448
|
verifyModeLabel={loginOrRegisterModal.verifyModeLabel}
|
|
447
449
|
socialLogins={loginOrRegisterModal.socialLogins}
|
|
450
|
+
socialPosition={loginOrRegisterModal.socialPosition}
|
|
448
451
|
socialDividerLabel={loginOrRegisterModal.socialDividerLabel}
|
|
452
|
+
animateHeight={loginOrRegisterModal.animateHeight}
|
|
449
453
|
footer={loginOrRegisterModal.footer}
|
|
450
454
|
{notifications}
|
|
451
455
|
title={loginOrRegisterModal.title}
|
|
@@ -39,7 +39,7 @@ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children">
|
|
|
39
39
|
* flip into verify mode (e.g., on a `requiresVerification` server response),
|
|
40
40
|
* the consumer updates its own `mode` state and the new value flows down.
|
|
41
41
|
*/
|
|
42
|
-
loginOrRegisterModal?: Pick<LoginOrRegisterFormModalProps, "title" | "classModal" | "classInner" | "classForm" | "noXClose" | "noClickOutsideClose" | "onClose" | "mode" | "verifyEmail" | "onLogin" | "onRegister" | "onVerify" | "onResendCode" | "onForgotPassword" | "onModeChange" | "isSubmitting" | "loginProps" | "registerProps" | "verifyProps" | "socialLogins" | "socialDividerLabel" | "footer" | "modeSwitcher" | "loginModeLabel" | "registerModeLabel" | "verifyModeLabel">;
|
|
42
|
+
loginOrRegisterModal?: Pick<LoginOrRegisterFormModalProps, "title" | "classModal" | "classInner" | "classForm" | "noXClose" | "noClickOutsideClose" | "onClose" | "mode" | "verifyEmail" | "onLogin" | "onRegister" | "onVerify" | "onResendCode" | "onForgotPassword" | "onModeChange" | "isSubmitting" | "loginProps" | "registerProps" | "verifyProps" | "socialLogins" | "socialPosition" | "socialDividerLabel" | "footer" | "animateHeight" | "modeSwitcher" | "loginModeLabel" | "registerModeLabel" | "verifyModeLabel">;
|
|
43
43
|
/** Tab label for the guest form tab. Default from i18n. */
|
|
44
44
|
guestTabLabel?: string;
|
|
45
45
|
/** Tab label for the login form tab. Default from i18n. */
|
|
@@ -132,6 +132,10 @@
|
|
|
132
132
|
scrollToFirstInvalidField,
|
|
133
133
|
validateAllFields,
|
|
134
134
|
} from "../../utils/validate-fields.js";
|
|
135
|
+
import {
|
|
136
|
+
createExternalFieldErrors,
|
|
137
|
+
repaintFieldErrors,
|
|
138
|
+
} from "../../utils/field-errors.svelte.js";
|
|
135
139
|
|
|
136
140
|
let {
|
|
137
141
|
formData = $bindable(createEmptyContactFormData()),
|
|
@@ -217,11 +221,70 @@
|
|
|
217
221
|
});
|
|
218
222
|
});
|
|
219
223
|
|
|
224
|
+
// Seed `ContactFieldConfig.initialValue` into `formData.extra`. `extraValue()`
|
|
225
|
+
// only ever used it as a *display* fallback, so an untouched field with a
|
|
226
|
+
// required `initialValue` failed validation ("… is required") while its value
|
|
227
|
+
// sat plainly visible in the input, and an untouched optional one submitted
|
|
228
|
+
// `undefined`. Never clobbers a value the consumer already provided. `.pre` so
|
|
229
|
+
// the seed lands before first paint; the bare `formData.extra` read tracks a
|
|
230
|
+
// wholesale replacement of the container without tracking the per-key writes.
|
|
231
|
+
$effect.pre(() => {
|
|
232
|
+
const seeds = extraFields
|
|
233
|
+
.filter((f) => f.initialValue != null)
|
|
234
|
+
.map((f) => [f.name, f.initialValue] as const);
|
|
235
|
+
void formData.extra;
|
|
236
|
+
if (!seeds.length) return;
|
|
237
|
+
untrack(() => {
|
|
238
|
+
if (!formData.extra) formData.extra = {};
|
|
239
|
+
for (const [name, initialValue] of seeds) {
|
|
240
|
+
if (formData.extra[name] == null) formData.extra[name] = initialValue;
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Give the consumer-owned `errors` prop a lifecycle: an entry for a field this
|
|
246
|
+
// form renders self-clears once the user edits it, instead of wedging the form
|
|
247
|
+
// forever (the field's customValidator kept reporting it, so every later
|
|
248
|
+
// submit was routed to `submit_invalid` — including the consumer's own handler
|
|
249
|
+
// that would have cleared the errors). Entries for anything else keep blocking
|
|
250
|
+
// until the consumer drops them. See `createExternalFieldErrors`.
|
|
251
|
+
const external = createExternalFieldErrors({
|
|
252
|
+
errors: () => externalErrors,
|
|
253
|
+
isRendered(field) {
|
|
254
|
+
if (field === "name") return showName;
|
|
255
|
+
if (field === "phone") return showPhone;
|
|
256
|
+
if (field === "subject") return subjectShown;
|
|
257
|
+
if (field === "company") return showCompany;
|
|
258
|
+
if (field === "email" || field === "message") return true;
|
|
259
|
+
return extraFields.some((f) => f.name === field);
|
|
260
|
+
},
|
|
261
|
+
valueOf(field) {
|
|
262
|
+
switch (field) {
|
|
263
|
+
case "name":
|
|
264
|
+
return formData.name ?? "";
|
|
265
|
+
case "email":
|
|
266
|
+
return formData.email ?? "";
|
|
267
|
+
case "phone":
|
|
268
|
+
return formData.phone ?? "";
|
|
269
|
+
case "subject":
|
|
270
|
+
return formData.subject ?? "";
|
|
271
|
+
case "company":
|
|
272
|
+
return formData.company ?? "";
|
|
273
|
+
case "message":
|
|
274
|
+
return formData.message ?? "";
|
|
275
|
+
default: {
|
|
276
|
+
const v = formData.extra?.[field];
|
|
277
|
+
return v == null ? "" : String(v);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
220
283
|
// Merge internal + external errors; external takes precedence per field.
|
|
221
284
|
let allErrors = $derived.by(() => {
|
|
222
285
|
const map = new Map<string, string>();
|
|
223
286
|
for (const e of internalErrors) map.set(e.field, e.message);
|
|
224
|
-
for (const e of
|
|
287
|
+
for (const e of external.live) map.set(e.field, e.message);
|
|
225
288
|
return [...map.entries()].map(([field, message]) => ({ field, message }));
|
|
226
289
|
});
|
|
227
290
|
|
|
@@ -274,7 +337,8 @@
|
|
|
274
337
|
|
|
275
338
|
// Report-only on bot signals: we still submit when field validation passes
|
|
276
339
|
// and hand the consumer the botCheck to enforce server-side.
|
|
277
|
-
if (validationErrors.length === 0 &&
|
|
340
|
+
if (validationErrors.length === 0 && external.live.length === 0) {
|
|
341
|
+
external.markSubmitted();
|
|
278
342
|
onSubmit(formData, buildBotCheck());
|
|
279
343
|
}
|
|
280
344
|
}
|
|
@@ -298,8 +362,10 @@
|
|
|
298
362
|
);
|
|
299
363
|
|
|
300
364
|
// Imperative API ----------------------------------------------------------
|
|
301
|
-
|
|
302
|
-
|
|
365
|
+
// Extra-field refs are keyed by `cfg.name`, NOT by `{#each}` index: the each
|
|
366
|
+
// block is keyed by name, so an index-based array pointed at the wrong
|
|
367
|
+
// component after any reorder of `extraFields`.
|
|
368
|
+
let extraFieldRefs = $state<Record<string, FieldInput | undefined>>({});
|
|
303
369
|
let nameField = $state<FieldInput>();
|
|
304
370
|
let emailField = $state<FieldInput>();
|
|
305
371
|
let phoneField = $state<FieldInput>();
|
|
@@ -309,22 +375,53 @@
|
|
|
309
375
|
|
|
310
376
|
function _fields() {
|
|
311
377
|
return [
|
|
312
|
-
...
|
|
378
|
+
...topFields.map((f) => extraFieldRefs[f.name]),
|
|
313
379
|
...(showName ? [nameField] : []),
|
|
314
380
|
emailField,
|
|
315
381
|
...(showPhone ? [phoneField] : []),
|
|
316
382
|
...(showCompany ? [companyField] : []),
|
|
317
383
|
...(subjectShown ? [subjectField] : []),
|
|
318
384
|
messageField,
|
|
319
|
-
...
|
|
385
|
+
...bottomFields.map((f) => extraFieldRefs[f.name]),
|
|
320
386
|
];
|
|
321
387
|
}
|
|
322
388
|
|
|
389
|
+
function _fieldByName(name: string) {
|
|
390
|
+
switch (name) {
|
|
391
|
+
case "name":
|
|
392
|
+
return showName ? nameField : undefined;
|
|
393
|
+
case "email":
|
|
394
|
+
return emailField;
|
|
395
|
+
case "phone":
|
|
396
|
+
return showPhone ? phoneField : undefined;
|
|
397
|
+
case "company":
|
|
398
|
+
return showCompany ? companyField : undefined;
|
|
399
|
+
case "subject":
|
|
400
|
+
return subjectShown ? subjectField : undefined;
|
|
401
|
+
case "message":
|
|
402
|
+
return messageField;
|
|
403
|
+
default:
|
|
404
|
+
return extraFields.some((f) => f.name === name)
|
|
405
|
+
? extraFieldRefs[name]
|
|
406
|
+
: undefined;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Paint newly-arrived error messages without waiting for the user's next
|
|
411
|
+
// interaction. `validateContactForm` runs on `submit_valid` — after the
|
|
412
|
+
// validity walk has already re-run every field's validator — and a server
|
|
413
|
+
// `errors` delivery lands once the submit is over, so without this the first
|
|
414
|
+
// failed click was a button that did nothing, with no message anywhere.
|
|
415
|
+
repaintFieldErrors(() => allErrors, _fieldByName);
|
|
416
|
+
|
|
323
417
|
/**
|
|
324
418
|
* Run every visible field's validator and render any inline errors. Returns
|
|
325
419
|
* true if all fields are valid. Useful from custom submit handlers.
|
|
326
420
|
*/
|
|
327
421
|
export function validate(): boolean {
|
|
422
|
+
// Consumers posting from their own handler never reach `handleSubmitValid`,
|
|
423
|
+
// so this has to arm the same "a round-trip is starting" flag.
|
|
424
|
+
external.markSubmitted();
|
|
328
425
|
return validateAllFields(_fields());
|
|
329
426
|
}
|
|
330
427
|
|
|
@@ -344,9 +441,9 @@
|
|
|
344
441
|
<DismissibleMessage message={error} intent="destructive" />
|
|
345
442
|
|
|
346
443
|
<!-- Top-position extra fields -->
|
|
347
|
-
{#each topFields as cfg
|
|
444
|
+
{#each topFields as cfg (cfg.name)}
|
|
348
445
|
<FieldInput
|
|
349
|
-
bind:this={
|
|
446
|
+
bind:this={extraFieldRefs[cfg.name]}
|
|
350
447
|
value={extraValue(cfg)}
|
|
351
448
|
oninput={(e: Event) =>
|
|
352
449
|
setExtraValue(cfg, (e.currentTarget as HTMLInputElement).value)}
|
|
@@ -511,9 +608,9 @@
|
|
|
511
608
|
/>
|
|
512
609
|
|
|
513
610
|
<!-- Bottom-position extra fields (default) -->
|
|
514
|
-
{#each bottomFields as cfg
|
|
611
|
+
{#each bottomFields as cfg (cfg.name)}
|
|
515
612
|
<FieldInput
|
|
516
|
-
bind:this={
|
|
613
|
+
bind:this={extraFieldRefs[cfg.name]}
|
|
517
614
|
value={extraValue(cfg)}
|
|
518
615
|
oninput={(e: Event) =>
|
|
519
616
|
setExtraValue(cfg, (e.currentTarget as HTMLInputElement).value)}
|
|
@@ -63,7 +63,7 @@ interface ContactFieldConfig {
|
|
|
63
63
|
| `formData` | `ContactFormData` | empty | Bindable form data. |
|
|
64
64
|
| `onSubmit` | `(data: ContactFormData, botCheck: ContactBotCheck) => void` | required | Called after client-side validation passes. `botCheck` is report-only. |
|
|
65
65
|
| `isSubmitting` | `boolean` | `false` | Disables the CTA during submission. |
|
|
66
|
-
| `errors` | `ContactFormValidationError[]` | `[]` | Field-specific server errors (merged with internal validation).
|
|
66
|
+
| `errors` | `ContactFormValidationError[]` | `[]` | Field-specific server errors (merged with internal validation); self-clearing (see below). |
|
|
67
67
|
| `error` | `string` | - | General error rendered as a `DismissibleMessage` above the form. |
|
|
68
68
|
| `showName` | `boolean` | `false` | Render the Name field. |
|
|
69
69
|
| `requireName` | `boolean` | `true` | Require Name (only applies when shown). |
|
|
@@ -193,6 +193,21 @@ automatically; the bound value is still the chosen string in `formData.subject`)
|
|
|
193
193
|
/>
|
|
194
194
|
```
|
|
195
195
|
|
|
196
|
+
## Server-supplied errors
|
|
197
|
+
|
|
198
|
+
`errors` is consumer-owned — the form renders it but cannot clear it. An entry for a field **this form renders** is therefore tied to the value that field held when the error arrived, and goes **stale** as soon as the user edits it: it stops blocking submit and disappears from the inline messages on that field's next validation run. Typing the rejected value back in makes it live again.
|
|
199
|
+
|
|
200
|
+
The rule in one line: **errors the user can fix here clear themselves; everything else is yours to clear.** Without the first half the form used to wedge permanently after any server-side field error — the consumer's own "clear errors on submit" code cannot help, because their submit handler is exactly what was being suppressed.
|
|
201
|
+
|
|
202
|
+
Notes:
|
|
203
|
+
|
|
204
|
+
- An error whose `field` isn't rendered here keeps blocking until you drop it from `errors`. Nothing in the form can answer it, and auto-clearing it would let the form post past a block you set deliberately.
|
|
205
|
+
- Messages are painted as soon as they arrive; no extra click is needed.
|
|
206
|
+
- Staleness is keyed on the errors' _content_, not the array identity, so passing a freshly built array on every render is safe. An identical error redelivered after a resubmit is treated as fresh.
|
|
207
|
+
- If you post from your own handler instead of `onSubmit`, call `validate()` first — that is what marks the round trip.
|
|
208
|
+
|
|
209
|
+
Shared with the other STUIC forms via `createExternalFieldErrors` (see the [utils domain](../../../docs/domains/utils.md)).
|
|
210
|
+
|
|
196
211
|
## CSS Variables
|
|
197
212
|
|
|
198
213
|
Prefix: `--stuic-contact-us-form-*`
|
|
@@ -45,7 +45,12 @@ export interface ContactFieldConfig {
|
|
|
45
45
|
placeholder?: string;
|
|
46
46
|
required?: boolean;
|
|
47
47
|
autocomplete?: HTMLInputAttributes["autocomplete"];
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Initial value seeded into `formData.extra[name]` when that key is null-ish.
|
|
50
|
+
* Seeded raw (your type is preserved), while user edits always write strings —
|
|
51
|
+
* so prefer a string here, otherwise a numeric seed reaches `validate` /
|
|
52
|
+
* `onSubmit` as a number before the first edit and as a string after it.
|
|
53
|
+
*/
|
|
49
54
|
initialValue?: unknown;
|
|
50
55
|
/**
|
|
51
56
|
* Synchronous validator. Return empty string / undefined for "valid".
|
|
@@ -85,6 +85,10 @@
|
|
|
85
85
|
scrollToFirstInvalidField,
|
|
86
86
|
validateAllFields,
|
|
87
87
|
} from "../../utils/validate-fields.js";
|
|
88
|
+
import {
|
|
89
|
+
createExternalFieldErrors,
|
|
90
|
+
repaintFieldErrors,
|
|
91
|
+
} from "../../utils/field-errors.svelte.js";
|
|
88
92
|
import Button from "../Button/Button.svelte";
|
|
89
93
|
import DismissibleMessage from "../DismissibleMessage/DismissibleMessage.svelte";
|
|
90
94
|
import FieldCheckbox from "../Input/FieldCheckbox.svelte";
|
|
@@ -135,11 +139,23 @@
|
|
|
135
139
|
// Internal validation errors (set on submit)
|
|
136
140
|
let internalErrors = $state<LoginFormValidationError[]>([]);
|
|
137
141
|
|
|
142
|
+
// Give the consumer-owned `errors` prop a lifecycle: an entry for a field this
|
|
143
|
+
// form renders self-clears once the user edits it, instead of wedging the form
|
|
144
|
+
// forever (the field's customValidator kept reporting it, so every later
|
|
145
|
+
// submit was routed to `submit_invalid` — including the consumer's own handler
|
|
146
|
+
// that would have cleared the errors). Entries for anything else keep blocking
|
|
147
|
+
// until the consumer drops them. See `createExternalFieldErrors`.
|
|
148
|
+
const external = createExternalFieldErrors({
|
|
149
|
+
errors: () => externalErrors,
|
|
150
|
+
isRendered: (field) => field === "email" || field === "password",
|
|
151
|
+
valueOf: (field) => (field === "email" ? formData.email : formData.password) ?? "",
|
|
152
|
+
});
|
|
153
|
+
|
|
138
154
|
// Merge internal + external errors; external takes precedence per field
|
|
139
155
|
let allErrors = $derived.by(() => {
|
|
140
156
|
const map = new Map<string, string>();
|
|
141
157
|
for (const e of internalErrors) map.set(e.field, e.message);
|
|
142
|
-
for (const e of
|
|
158
|
+
for (const e of external.live) map.set(e.field, e.message);
|
|
143
159
|
return [...map.entries()].map(([field, message]) => ({ field, message }));
|
|
144
160
|
});
|
|
145
161
|
|
|
@@ -180,7 +196,8 @@
|
|
|
180
196
|
// validationErrors.length === 0 && externalErrors.length === 0,
|
|
181
197
|
// });
|
|
182
198
|
|
|
183
|
-
if (validationErrors.length === 0 &&
|
|
199
|
+
if (validationErrors.length === 0 && external.live.length === 0) {
|
|
200
|
+
external.markSubmitted();
|
|
184
201
|
onSubmit(formData);
|
|
185
202
|
}
|
|
186
203
|
}
|
|
@@ -228,11 +245,27 @@
|
|
|
228
245
|
return [emailField, passwordField];
|
|
229
246
|
}
|
|
230
247
|
|
|
248
|
+
function _fieldByName(name: string): FieldInput | undefined {
|
|
249
|
+
if (name === "email") return emailField;
|
|
250
|
+
if (name === "password") return passwordField;
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Paint newly-arrived error messages without waiting for the user's next
|
|
255
|
+
// interaction. `validateLoginForm` runs on `submit_valid` — after the validity
|
|
256
|
+
// walk has already re-run every field's validator — and a server `errors`
|
|
257
|
+
// delivery lands once the submit is over, so without this the first failed
|
|
258
|
+
// click was a button that did nothing, with no message anywhere.
|
|
259
|
+
repaintFieldErrors(() => allErrors, _fieldByName);
|
|
260
|
+
|
|
231
261
|
/**
|
|
232
262
|
* Run every field's validator and render any inline errors. Returns true
|
|
233
263
|
* if all fields are valid. Useful from custom submit handlers.
|
|
234
264
|
*/
|
|
235
265
|
export function validate(): boolean {
|
|
266
|
+
// Consumers posting from their own handler never reach `handleSubmitValid`,
|
|
267
|
+
// so this has to arm the same "a round-trip is starting" flag.
|
|
268
|
+
external.markSubmitted();
|
|
236
269
|
return validateAllFields(_fields());
|
|
237
270
|
}
|
|
238
271
|
|
|
@@ -252,9 +285,11 @@
|
|
|
252
285
|
<DismissibleMessage message={error} intent="destructive" />
|
|
253
286
|
|
|
254
287
|
<!--
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
288
|
+
NOTE on `binding_property_non_reactive`: formData is a $bindable prop — deep
|
|
289
|
+
reactivity depends on the consumer passing a $state() object. The bindings
|
|
290
|
+
work correctly regardless; the per-field directives below silence the hint.
|
|
291
|
+
(This block intentionally does NOT start with the literal directive word so
|
|
292
|
+
it isn't parsed as one — every following word would become a bogus code.)
|
|
258
293
|
-->
|
|
259
294
|
<!-- Email -->
|
|
260
295
|
<!-- svelte-ignore binding_property_non_reactive -->
|
|
@@ -19,25 +19,25 @@ Standalone login form with email/password fields, optional social/OAuth buttons,
|
|
|
19
19
|
|
|
20
20
|
## LoginForm — Props
|
|
21
21
|
|
|
22
|
-
| Prop | Type | Default | Description
|
|
23
|
-
| -------------------- | --------------------------------------- | -------- |
|
|
24
|
-
| `formData` | `LoginFormData` | empty | Bindable form data.
|
|
25
|
-
| `onSubmit` | `(data: LoginFormData) => void` | required | Called when client-side validation passes.
|
|
26
|
-
| `isSubmitting` | `boolean` | `false` | Disables the CTA during submission.
|
|
27
|
-
| `errors` | `LoginFormValidationError[]` | `[]` | Field-specific server errors (merged with internal validation).
|
|
28
|
-
| `error` | `string` | - | General error rendered as a `DismissibleMessage` above the form.
|
|
29
|
-
| `onForgotPassword` | `() => void` | - | Click handler for the "Forgot password?" link. Link is hidden when undefined.
|
|
30
|
-
| `showRememberMe` | `boolean` | `true` | Render the remember-me checkbox.
|
|
31
|
-
| `submitLabel` | `string` | i18n | Override the CTA label.
|
|
32
|
-
| `submittingLabel` | `string` | i18n | Override the CTA label while submitting.
|
|
33
|
-
| `submitButton` | `Snippet<[{ isSubmitting, disabled }]>` | - | Override the entire CTA section.
|
|
34
|
-
| `socialLogins` | `Snippet` | - | Social/OAuth buttons rendered below the form. A divider is shown above when set.
|
|
35
|
-
| `socialDividerLabel` | `string \| false` | i18n | Override (or hide with `false`) the divider above social buttons.
|
|
36
|
-
| `footer` | `Snippet` | - | Content below the form (e.g., sign-up links).
|
|
37
|
-
| `notifications` | `NotificationsStack` | - | When set, general errors are also pushed via `notifications.error()`.
|
|
38
|
-
| `t` | `TranslateFn` | English | i18n function.
|
|
39
|
-
| `unstyled` / `class` | - | - | Standard styling escape hatches.
|
|
40
|
-
| `el` | `HTMLFormElement` | - | Bindable form element.
|
|
22
|
+
| Prop | Type | Default | Description |
|
|
23
|
+
| -------------------- | --------------------------------------- | -------- | ------------------------------------------------------------------------------------------ |
|
|
24
|
+
| `formData` | `LoginFormData` | empty | Bindable form data. |
|
|
25
|
+
| `onSubmit` | `(data: LoginFormData) => void` | required | Called when client-side validation passes. |
|
|
26
|
+
| `isSubmitting` | `boolean` | `false` | Disables the CTA during submission. |
|
|
27
|
+
| `errors` | `LoginFormValidationError[]` | `[]` | Field-specific server errors (merged with internal validation); self-clearing (see below). |
|
|
28
|
+
| `error` | `string` | - | General error rendered as a `DismissibleMessage` above the form. |
|
|
29
|
+
| `onForgotPassword` | `() => void` | - | Click handler for the "Forgot password?" link. Link is hidden when undefined. |
|
|
30
|
+
| `showRememberMe` | `boolean` | `true` | Render the remember-me checkbox. |
|
|
31
|
+
| `submitLabel` | `string` | i18n | Override the CTA label. |
|
|
32
|
+
| `submittingLabel` | `string` | i18n | Override the CTA label while submitting. |
|
|
33
|
+
| `submitButton` | `Snippet<[{ isSubmitting, disabled }]>` | - | Override the entire CTA section. |
|
|
34
|
+
| `socialLogins` | `Snippet` | - | Social/OAuth buttons rendered below the form. A divider is shown above when set. |
|
|
35
|
+
| `socialDividerLabel` | `string \| false` | i18n | Override (or hide with `false`) the divider above social buttons. |
|
|
36
|
+
| `footer` | `Snippet` | - | Content below the form (e.g., sign-up links). |
|
|
37
|
+
| `notifications` | `NotificationsStack` | - | When set, general errors are also pushed via `notifications.error()`. |
|
|
38
|
+
| `t` | `TranslateFn` | English | i18n function. |
|
|
39
|
+
| `unstyled` / `class` | - | - | Standard styling escape hatches. |
|
|
40
|
+
| `el` | `HTMLFormElement` | - | Bindable form element. |
|
|
41
41
|
|
|
42
42
|
### Imperative methods
|
|
43
43
|
|
|
@@ -127,6 +127,21 @@ Inherits all `LoginForm` props, plus:
|
|
|
127
127
|
<Button onclick={submit}>Submit from outside</Button>
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
+
## Server-supplied errors
|
|
131
|
+
|
|
132
|
+
`errors` is consumer-owned — the form renders it but cannot clear it. An entry for a field **this form renders** is therefore tied to the value that field held when the error arrived, and goes **stale** as soon as the user edits it: it stops blocking submit and disappears from the inline messages on that field's next validation run. Typing the rejected value back in makes it live again.
|
|
133
|
+
|
|
134
|
+
The rule in one line: **errors the user can fix here clear themselves; everything else is yours to clear.** Without the first half the form used to wedge permanently after any server-side field error — the consumer's own "clear errors on submit" code cannot help, because their submit handler is exactly what was being suppressed.
|
|
135
|
+
|
|
136
|
+
Notes:
|
|
137
|
+
|
|
138
|
+
- An error whose `field` isn't rendered here keeps blocking until you drop it from `errors`. Nothing in the form can answer it, and auto-clearing it would let the form post past a block you set deliberately.
|
|
139
|
+
- Messages are painted as soon as they arrive; no extra click is needed.
|
|
140
|
+
- Staleness is keyed on the errors' _content_, not the array identity, so passing a freshly built array on every render is safe. An identical error redelivered after a resubmit is treated as fresh.
|
|
141
|
+
- If you post from your own handler instead of `onSubmit`, call `validate()` first — that is what marks the round trip.
|
|
142
|
+
|
|
143
|
+
Shared with the other STUIC forms via `createExternalFieldErrors` (see the [utils domain](../../../docs/domains/utils.md)).
|
|
144
|
+
|
|
130
145
|
## CSS Variables
|
|
131
146
|
|
|
132
147
|
Prefix: `--stuic-login-form-*`
|