@marianmeres/stuic 3.157.0 → 3.158.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.
Files changed (36) hide show
  1. package/dist/components/Checkout/CheckoutGuestForm.svelte +124 -5
  2. package/dist/components/Checkout/CheckoutGuestOrLoginForm.svelte +4 -0
  3. package/dist/components/Checkout/CheckoutGuestOrLoginForm.svelte.d.ts +1 -1
  4. package/dist/components/ContactUsForm/ContactUsForm.svelte +107 -10
  5. package/dist/components/ContactUsForm/README.md +16 -1
  6. package/dist/components/ContactUsForm/_internal/contact-form-types.d.ts +6 -1
  7. package/dist/components/LoginForm/LoginForm.svelte +40 -5
  8. package/dist/components/LoginForm/README.md +34 -19
  9. package/dist/components/LoginOrRegisterForm/LoginOrRegisterForm.svelte +73 -16
  10. package/dist/components/LoginOrRegisterForm/LoginOrRegisterForm.svelte.d.ts +12 -1
  11. package/dist/components/LoginOrRegisterForm/LoginOrRegisterFormModal.svelte +32 -1
  12. package/dist/components/LoginOrRegisterForm/LoginOrRegisterFormModal.svelte.d.ts +7 -1
  13. package/dist/components/LoginOrRegisterForm/README.md +45 -29
  14. package/dist/components/LoginOrRegisterForm/_internal/login-or-register-form-i18n-defaults.js +1 -0
  15. package/dist/components/LoginOrRegisterForm/index.css +18 -0
  16. package/dist/components/RegisterForm/README.md +177 -37
  17. package/dist/components/RegisterForm/RegisterForm.svelte +329 -76
  18. package/dist/components/RegisterForm/RegisterForm.svelte.d.ts +77 -3
  19. package/dist/components/RegisterForm/RegisterFormModal.svelte +78 -0
  20. package/dist/components/RegisterForm/RegisterFormModal.svelte.d.ts +31 -0
  21. package/dist/components/RegisterForm/_internal/register-form-i18n-defaults.js +1 -0
  22. package/dist/components/RegisterForm/_internal/register-form-types.d.ts +12 -2
  23. package/dist/components/RegisterForm/_internal/register-form-utils.d.ts +19 -2
  24. package/dist/components/RegisterForm/_internal/register-form-utils.js +34 -17
  25. package/dist/components/RegisterForm/index.css +33 -2
  26. package/dist/components/RegisterForm/index.d.ts +2 -1
  27. package/dist/components/RegisterForm/index.js +1 -1
  28. package/dist/utils/field-errors.svelte.d.ts +114 -0
  29. package/dist/utils/field-errors.svelte.js +131 -0
  30. package/dist/utils/index.d.ts +1 -0
  31. package/dist/utils/index.js +1 -0
  32. package/docs/domains/components.md +54 -38
  33. package/docs/domains/utils.md +57 -0
  34. package/package.json +2 -2
  35. package/dist/components/Input/node_modules/.vite/vitest/d2a04d71301a8915217dd5faf81d12cffd6cd958/_svelte_metadata.json +0 -1
  36. 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 externalErrors) map.set(e.field, e.message);
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 && externalErrors.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
- svelte-ignore binding_property_non_reactive:
205
- formData is a $bindable prop deep reactivity depends on the consumer
206
- passing a $state() object. The bindings work correctly regardless.
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 externalErrors) map.set(e.field, e.message);
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 && externalErrors.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
- let topFieldRefs: (FieldInput | undefined)[] = $state([]);
302
- let bottomFieldRefs: (FieldInput | undefined)[] = $state([]);
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
- ...topFieldRefs,
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
- ...bottomFieldRefs,
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, i (cfg.name)}
444
+ {#each topFields as cfg (cfg.name)}
348
445
  <FieldInput
349
- bind:this={topFieldRefs[i]}
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, i (cfg.name)}
611
+ {#each bottomFields as cfg (cfg.name)}
515
612
  <FieldInput
516
- bind:this={bottomFieldRefs[i]}
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
- /** Initial value to seed into `formData.extra[name]` if undefined. */
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 externalErrors) map.set(e.field, e.message);
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 && externalErrors.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
- svelte-ignore binding_property_non_reactive:
256
- formData is a $bindable prop deep reactivity depends on the consumer
257
- passing a $state() object. The bindings work correctly regardless.
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-*`