@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
@@ -2,6 +2,8 @@
2
2
  import type { Snippet } from "svelte";
3
3
  import type { HTMLAttributes } from "svelte/elements";
4
4
  import type { TranslateFn } from "../../types.js";
5
+ import type { Props as FieldInputProps } from "../Input/FieldInput.svelte";
6
+ import type { ValidateOptions } from "../../actions/validate.svelte.js";
5
7
  import type {
6
8
  RegisterFieldConfig,
7
9
  RegisterFormData,
@@ -19,6 +21,18 @@
19
21
  /** Whether the form is currently submitting (disables CTA) */
20
22
  isSubmitting?: boolean;
21
23
 
24
+ /**
25
+ * Consumer-owned reason to refuse submission which is neither "submitting"
26
+ * nor a field error — e.g. an external identity whose server-side TTL has
27
+ * lapsed, an unchecked terms box rendered in `extraFieldsSlot`, or an async
28
+ * availability check still in flight.
29
+ *
30
+ * Disables the default CTA, is reflected in the `submitButton` snippet
31
+ * payload, and also prevents `onSubmit` from firing at all — so a custom
32
+ * `submitButton` which ignores `disabled` still cannot post. Default: false
33
+ */
34
+ submitDisabled?: boolean;
35
+
22
36
  /** Field-specific validation errors (e.g., from server) */
23
37
  errors?: RegisterFormValidationError[];
24
38
 
@@ -29,12 +43,67 @@
29
43
  */
30
44
  error?: string;
31
45
 
32
- /** Show password confirmation field. Default: true */
46
+ /**
47
+ * Render the built-in email field. When false the field is UNMOUNTED (not
48
+ * hidden) and skipped by validation — use it when the account identity is
49
+ * established elsewhere (OAuth provider, invite token, magic link) and pair
50
+ * it with `credentialsSlot`. Default: true
51
+ */
52
+ showEmail?: boolean;
53
+
54
+ /**
55
+ * Render the built-in password field. When false it (and, transitively, the
56
+ * confirm field) is UNMOUNTED and skipped by validation — e.g. passwordless
57
+ * / identity-first signup. Default: true
58
+ */
59
+ showPassword?: boolean;
60
+
61
+ /**
62
+ * Show password confirmation field. Subordinate to `showPassword`:
63
+ * `showPassword={false}` implies no confirm field regardless. Default: true
64
+ */
33
65
  showPasswordConfirm?: boolean;
34
66
 
35
67
  /** Minimum password length. Fed into FieldInput `minlength` + validator. Default: 8 */
36
68
  passwordMinLength?: number;
37
69
 
70
+ /**
71
+ * Rendered at the credentials position — after whatever core fields are
72
+ * still shown, before the bottom-position extra fields.
73
+ *
74
+ * Combined with `showEmail` / `showPassword` = false this REPLACES the
75
+ * credential block: use it for an identity established elsewhere ("Signed
76
+ * in as x@y.z — change / use a password instead"), an invite summary, or a
77
+ * magic-link explainer. Receives `formData` and a `fieldError(name)` lookup.
78
+ */
79
+ credentialsSlot?: Snippet<
80
+ [
81
+ {
82
+ formData: RegisterFormData;
83
+ fieldError: (name: string) => string | undefined;
84
+ },
85
+ ]
86
+ >;
87
+
88
+ /**
89
+ * Passthrough props merged onto the built-in email `FieldInput`, applied
90
+ * AFTER the component's own — so `label`, `placeholder`, `description`,
91
+ * `autocomplete`, `renderSize`, `classInput`… are overridable without
92
+ * supplying a full `t`.
93
+ *
94
+ * `validate` is composed rather than replaced: your `customValidator` runs
95
+ * when there is no server/internal error for the field, so the `errors`
96
+ * wiring can't be knocked out by accident (`validate: false` is ignored for
97
+ * the same reason). `value` is ignored — the field is bound to `formData`.
98
+ */
99
+ emailFieldProps?: Partial<FieldInputProps>;
100
+
101
+ /** Passthrough props for the built-in password field. See `emailFieldProps`. */
102
+ passwordFieldProps?: Partial<FieldInputProps>;
103
+
104
+ /** Passthrough props for the built-in confirm field. See `emailFieldProps`. */
105
+ passwordConfirmFieldProps?: Partial<FieldInputProps>;
106
+
38
107
  /**
39
108
  * Declarative extra fields rendered as FieldInput entries.
40
109
  * Values bind into `formData.extra[name]`.
@@ -72,8 +141,23 @@
72
141
  socialLogins?: Snippet;
73
142
 
74
143
  /**
75
- * Override the divider label above social login buttons.
76
- * Default: i18n key "register_form.social_divider" ("or continue with").
144
+ * Where the social block sits relative to the credentials:
145
+ * - `"bottom"` (default) — after the CTA, divider ABOVE the buttons
146
+ * - `"top"` — between the top-position extra fields and the credentials,
147
+ * divider BELOW the buttons
148
+ *
149
+ * `"top"` suits identity-first signup, where the provider button is an
150
+ * alternative to the credentials only — fields required on both paths
151
+ * (workspace id, invite code…) stay above the choice. Default: "bottom"
152
+ */
153
+ socialPosition?: "top" | "bottom";
154
+
155
+ /**
156
+ * Override the divider label next to the social login buttons.
157
+ * Default: i18n key "register_form.social_divider" ("or continue with") at
158
+ * the bottom position, "register_form.social_divider_alt" ("or") at the top
159
+ * one — where the divider faces the credentials, so "or continue with"
160
+ * would read backwards.
77
161
  * Set to `false` to hide the divider while still rendering socialLogins.
78
162
  */
79
163
  socialDividerLabel?: string | false;
@@ -110,22 +194,34 @@
110
194
  scrollToFirstInvalidField,
111
195
  validateAllFields,
112
196
  } from "../../utils/validate-fields.js";
197
+ import {
198
+ createExternalFieldErrors,
199
+ repaintFieldErrors,
200
+ } from "../../utils/field-errors.svelte.js";
113
201
 
114
202
  let {
115
203
  formData = $bindable(createEmptyRegisterFormData()),
116
204
  onSubmit,
117
205
  isSubmitting = false,
206
+ submitDisabled = false,
118
207
  errors: externalErrors = [],
119
208
  error,
120
209
  notifications,
210
+ showEmail = true,
211
+ showPassword = true,
121
212
  showPasswordConfirm = true,
122
213
  passwordMinLength = 8,
214
+ credentialsSlot,
215
+ emailFieldProps,
216
+ passwordFieldProps,
217
+ passwordConfirmFieldProps,
123
218
  extraFields = [],
124
219
  extraFieldsSlot,
125
220
  submitLabel,
126
221
  submittingLabel,
127
222
  submitButton,
128
223
  socialLogins,
224
+ socialPosition = "bottom",
129
225
  socialDividerLabel,
130
226
  footer,
131
227
  t: tProp,
@@ -148,6 +244,11 @@
148
244
  let topFields = $derived(extraFields.filter((f) => f.position === "top"));
149
245
  let bottomFields = $derived(extraFields.filter((f) => f.position !== "top"));
150
246
 
247
+ // `showPasswordConfirm` is subordinate to `showPassword` — a confirm field
248
+ // without the password it confirms is meaningless (and would still be
249
+ // validated against it).
250
+ let renderPasswordConfirm = $derived(showPassword && showPasswordConfirm);
251
+
151
252
  // Internal validation errors (set on submit)
152
253
  let internalErrors = $state<RegisterFormValidationError[]>([]);
153
254
 
@@ -167,11 +268,54 @@
167
268
  });
168
269
  });
169
270
 
271
+ // Seed `RegisterFieldConfig.initialValue` into `formData.extra`. `extraValue()`
272
+ // only ever used it as a *display* fallback, so an untouched field with a
273
+ // required `initialValue` failed validation ("… is required") while its value
274
+ // sat plainly visible in the input, and an untouched optional one submitted
275
+ // `undefined`. Never clobbers a value the consumer already provided.
276
+ // `.pre` so the seed lands before the first paint; the bare `formData.extra`
277
+ // read tracks a wholesale replacement of the container (which would drop the
278
+ // seeds) without tracking the per-key writes below, so it converges.
279
+ $effect.pre(() => {
280
+ const seeds = extraFields
281
+ .filter((f) => f.initialValue != null)
282
+ .map((f) => [f.name, f.initialValue] as const);
283
+ void formData.extra;
284
+ if (!seeds.length) return;
285
+ untrack(() => {
286
+ if (!formData.extra) formData.extra = {};
287
+ for (const [name, initialValue] of seeds) {
288
+ if (formData.extra[name] == null) formData.extra[name] = initialValue;
289
+ }
290
+ });
291
+ });
292
+
293
+ // Give the consumer-owned `errors` prop a lifecycle: an error on a field this
294
+ // form renders self-clears once the user edits that field; an error on
295
+ // anything else keeps blocking until the consumer drops it. See
296
+ // `createExternalFieldErrors` for the full rationale.
297
+ const external = createExternalFieldErrors({
298
+ errors: () => externalErrors,
299
+ isRendered(field) {
300
+ if (field === "email") return showEmail;
301
+ if (field === "password") return showPassword;
302
+ if (field === "passwordConfirm") return renderPasswordConfirm;
303
+ return extraFields.some((f) => f.name === field);
304
+ },
305
+ valueOf(field) {
306
+ if (field === "email") return formData.email ?? "";
307
+ if (field === "password") return formData.password ?? "";
308
+ if (field === "passwordConfirm") return formData.passwordConfirm ?? "";
309
+ const v = formData.extra?.[field];
310
+ return v == null ? "" : String(v);
311
+ },
312
+ });
313
+
170
314
  // Merge internal + external errors; external takes precedence per field
171
315
  let allErrors = $derived.by(() => {
172
316
  const map = new Map<string, string>();
173
317
  for (const e of internalErrors) map.set(e.field, e.message);
174
- for (const e of externalErrors) map.set(e.field, e.message);
318
+ for (const e of external.live) map.set(e.field, e.message);
175
319
  return [...map.entries()].map(([field, message]) => ({ field, message }));
176
320
  });
177
321
 
@@ -179,6 +323,29 @@
179
323
  return allErrors.find((e) => e.field === field)?.message;
180
324
  }
181
325
 
326
+ // Core-field passthrough. `validate` is pulled out of the spread and composed
327
+ // instead of overwritten: silently replacing it would remove the only render
328
+ // path for `errors` while the submit gate still blocks — i.e. reintroduce the
329
+ // very deadlock above, invisibly. (No type can guard this: FieldInput's Props
330
+ // extend `Record<string, any>`, so `Omit` is a no-op there.)
331
+ function _fieldProps(p: Partial<FieldInputProps> | undefined) {
332
+ if (!p) return undefined;
333
+ const { validate: _ignored, value: _bound, ...rest } = p;
334
+ return rest;
335
+ }
336
+
337
+ function _fieldValidate(field: string, p: Partial<FieldInputProps> | undefined) {
338
+ const own = p?.validate && typeof p.validate === "object" ? p.validate : undefined;
339
+ return {
340
+ ...own,
341
+ customValidator(
342
+ ...args: Parameters<NonNullable<ValidateOptions["customValidator"]>>
343
+ ) {
344
+ return fieldError(field) || own?.customValidator?.(...args) || "";
345
+ },
346
+ };
347
+ }
348
+
182
349
  function extraValue(cfg: RegisterFieldConfig): string {
183
350
  const v = formData.extra?.[cfg.name];
184
351
  if (v == null) return typeof cfg.initialValue === "string" ? cfg.initialValue : "";
@@ -192,13 +359,20 @@
192
359
  }
193
360
 
194
361
  function handleSubmitValid() {
362
+ // Hard block, not just a disabled CTA — a custom `submitButton` that ignores
363
+ // the payload's `disabled` must not post either (nor post twice).
364
+ if (submitDisabled || isSubmitting) return;
365
+
195
366
  const validationErrors = validateRegisterForm(formData, t, extraFields, {
367
+ showEmail,
368
+ showPassword,
196
369
  showPasswordConfirm,
197
370
  passwordMinLength,
198
371
  });
199
372
  internalErrors = validationErrors;
200
373
 
201
- if (validationErrors.length === 0 && externalErrors.length === 0) {
374
+ if (validationErrors.length === 0 && external.live.length === 0) {
375
+ external.markSubmitted();
202
376
  onSubmit(formData);
203
377
  }
204
378
  }
@@ -222,27 +396,49 @@
222
396
  let _class = $derived(unstyled ? classProp : twMerge("stuic-register-form", classProp));
223
397
 
224
398
  // Imperative API ----------------------------------------------------------
225
- let topFieldRefs: (FieldInput | undefined)[] = $state([]);
226
- let bottomFieldRefs: (FieldInput | undefined)[] = $state([]);
399
+ // Extra-field refs are keyed by `cfg.name`, NOT by `{#each}` index: the each
400
+ // block is keyed by name, so an index-based array pointed at the wrong
401
+ // component after any reorder — and `focusField()` wants the by-name map anyway.
402
+ let extraFieldRefs = $state<Record<string, FieldInput | undefined>>({});
227
403
  let emailField = $state<FieldInput>();
228
404
  let passwordField = $state<FieldInput>();
229
405
  let passwordConfirmField = $state<FieldInput>();
230
406
 
407
+ /** Currently rendered fields, in display order. */
231
408
  function _fields() {
232
409
  return [
233
- ...topFieldRefs,
234
- emailField,
235
- passwordField,
236
- ...(showPasswordConfirm ? [passwordConfirmField] : []),
237
- ...bottomFieldRefs,
410
+ ...topFields.map((f) => extraFieldRefs[f.name]),
411
+ ...(showEmail ? [emailField] : []),
412
+ ...(showPassword ? [passwordField] : []),
413
+ ...(renderPasswordConfirm ? [passwordConfirmField] : []),
414
+ ...bottomFields.map((f) => extraFieldRefs[f.name]),
238
415
  ];
239
416
  }
240
417
 
418
+ /** Currently rendered field by name, or undefined. */
419
+ function _fieldByName(name: string): FieldInput | undefined {
420
+ if (name === "email") return showEmail ? emailField : undefined;
421
+ if (name === "password") return showPassword ? passwordField : undefined;
422
+ if (name === "passwordConfirm")
423
+ return renderPasswordConfirm ? passwordConfirmField : undefined;
424
+ return extraFields.some((f) => f.name === name) ? extraFieldRefs[name] : undefined;
425
+ }
426
+
427
+ // Paint newly-arrived error messages without waiting for the user's next
428
+ // interaction — otherwise the first failed click is a button that does
429
+ // nothing, with no message anywhere.
430
+ repaintFieldErrors(() => allErrors, _fieldByName);
431
+
241
432
  /**
242
433
  * Run every field's validator and render any inline errors. Returns true
243
434
  * if all fields are valid. Useful from custom submit handlers.
244
435
  */
245
436
  export function validate(): boolean {
437
+ // A consumer running their own submit path calls this instead of going
438
+ // through `handleSubmitValid`, so it has to arm the same "a round-trip is
439
+ // starting" flag — otherwise a byte-identical repeat rejection would be
440
+ // mistaken for the previous, already-answered one and stay suppressed.
441
+ external.markSubmitted();
246
442
  return validateAllFields(_fields());
247
443
  }
248
444
 
@@ -255,16 +451,70 @@
255
451
  ): boolean {
256
452
  return scrollToFirstInvalidField(_fields(), opts);
257
453
  }
454
+
455
+ /**
456
+ * Focus a field by name: "email" | "password" | "passwordConfirm", or any
457
+ * `extraFields` name (core names win on a collision). Returns false if the
458
+ * field is not currently rendered.
459
+ *
460
+ * Useful when the credential block is swapped out under the user (the control
461
+ * they last touched unmounts, so focus would otherwise fall to `<body>`), or
462
+ * to put the cursor in a field the server rejected — `scrollToFirstError()` is
463
+ * the wrong tool there, it depends on the internal validator having failed.
464
+ */
465
+ export function focusField(name: string): boolean {
466
+ const field = _fieldByName(name);
467
+ if (!field?.focus) return false;
468
+ field.focus();
469
+ return true;
470
+ }
258
471
  </script>
259
472
 
473
+ {#snippet socialDivider(position: "top" | "bottom")}
474
+ <div class={unstyled ? undefined : "stuic-register-form-social-divider"}>
475
+ <span>
476
+ {typeof socialDividerLabel === "string"
477
+ ? socialDividerLabel
478
+ : t(
479
+ position === "top"
480
+ ? "register_form.social_divider_alt"
481
+ : "register_form.social_divider"
482
+ )}
483
+ </span>
484
+ </div>
485
+ {/snippet}
486
+
487
+ <!--
488
+ Same markup from either position — only the divider flips sides, so it always
489
+ faces the credentials: above the buttons when "bottom", below them when "top".
490
+ -->
491
+ {#snippet socialBlock(position: "top" | "bottom")}
492
+ {#if socialLogins}
493
+ <div
494
+ class={unstyled ? undefined : "stuic-register-form-social"}
495
+ data-position={unstyled ? undefined : position}
496
+ >
497
+ {#if socialDividerLabel !== false && position === "bottom"}
498
+ {@render socialDivider(position)}
499
+ {/if}
500
+ <div class={unstyled ? undefined : "stuic-register-form-social-buttons"}>
501
+ {@render socialLogins()}
502
+ </div>
503
+ {#if socialDividerLabel !== false && position === "top"}
504
+ {@render socialDivider(position)}
505
+ {/if}
506
+ </div>
507
+ {/if}
508
+ {/snippet}
509
+
260
510
  <form bind:this={formEl} class={_class} use:onSubmitValidityCheck novalidate {...rest}>
261
511
  <!-- General error alert -->
262
512
  <DismissibleMessage message={error} intent="destructive" />
263
513
 
264
514
  <!-- Top-position extra fields -->
265
- {#each topFields as cfg, i (cfg.name)}
515
+ {#each topFields as cfg (cfg.name)}
266
516
  <FieldInput
267
- bind:this={topFieldRefs[i]}
517
+ bind:this={extraFieldRefs[cfg.name]}
268
518
  value={extraValue(cfg)}
269
519
  oninput={(e: Event) =>
270
520
  setExtraValue(cfg, (e.currentTarget as HTMLInputElement).value)}
@@ -284,52 +534,57 @@
284
534
  />
285
535
  {/each}
286
536
 
537
+ <!-- Social logins above the credentials -->
538
+ {#if socialPosition === "top"}
539
+ {@render socialBlock("top")}
540
+ {/if}
541
+
287
542
  <!--
288
- svelte-ignore binding_property_non_reactive:
543
+ The per-field `binding_property_non_reactive` ignores below are deliberate:
289
544
  formData is a $bindable prop — deep reactivity depends on the consumer
290
545
  passing a $state() object. The bindings work correctly regardless.
546
+ (Do not start this comment with the ignore directive itself — every
547
+ following word would be parsed as another, bogus, ignore code.)
291
548
  -->
292
549
  <!-- Email -->
293
- <!-- svelte-ignore binding_property_non_reactive -->
294
- <FieldInput
295
- bind:this={emailField}
296
- bind:value={formData.email}
297
- label={t("register_form.email_label")}
298
- type="email"
299
- placeholder={t("register_form.email_placeholder")}
300
- autocomplete="email"
301
- required
302
- name="register-email"
303
- labelLeftBreakpoint={0}
304
- validate={{
305
- customValidator() {
306
- return fieldError("email") || "";
307
- },
308
- }}
309
- />
550
+ {#if showEmail}
551
+ <!-- svelte-ignore binding_property_non_reactive -->
552
+ <FieldInput
553
+ bind:this={emailField}
554
+ bind:value={formData.email}
555
+ label={t("register_form.email_label")}
556
+ type="email"
557
+ placeholder={t("register_form.email_placeholder")}
558
+ autocomplete="email"
559
+ required
560
+ name="register-email"
561
+ labelLeftBreakpoint={0}
562
+ validate={_fieldValidate("email", emailFieldProps)}
563
+ {..._fieldProps(emailFieldProps)}
564
+ />
565
+ {/if}
310
566
 
311
567
  <!-- Password -->
312
- <!-- svelte-ignore binding_property_non_reactive -->
313
- <FieldInput
314
- bind:this={passwordField}
315
- bind:value={formData.password}
316
- label={t("register_form.password_label")}
317
- autocomplete="new-password"
318
- type="password"
319
- placeholder={t("register_form.password_placeholder")}
320
- required
321
- minlength={passwordMinLength}
322
- name="register-password"
323
- labelLeftBreakpoint={0}
324
- validate={{
325
- customValidator() {
326
- return fieldError("password") || "";
327
- },
328
- }}
329
- />
568
+ {#if showPassword}
569
+ <!-- svelte-ignore binding_property_non_reactive -->
570
+ <FieldInput
571
+ bind:this={passwordField}
572
+ bind:value={formData.password}
573
+ label={t("register_form.password_label")}
574
+ autocomplete="new-password"
575
+ type="password"
576
+ placeholder={t("register_form.password_placeholder")}
577
+ required
578
+ minlength={passwordMinLength}
579
+ name="register-password"
580
+ labelLeftBreakpoint={0}
581
+ validate={_fieldValidate("password", passwordFieldProps)}
582
+ {..._fieldProps(passwordFieldProps)}
583
+ />
584
+ {/if}
330
585
 
331
586
  <!-- Password confirm -->
332
- {#if showPasswordConfirm}
587
+ {#if renderPasswordConfirm}
333
588
  <!-- svelte-ignore binding_property_non_reactive -->
334
589
  <FieldInput
335
590
  bind:this={passwordConfirmField}
@@ -342,18 +597,24 @@
342
597
  minlength={passwordMinLength}
343
598
  name="register-password-confirm"
344
599
  labelLeftBreakpoint={0}
345
- validate={{
346
- customValidator() {
347
- return fieldError("passwordConfirm") || "";
348
- },
349
- }}
600
+ validate={_fieldValidate("passwordConfirm", passwordConfirmFieldProps)}
601
+ {..._fieldProps(passwordConfirmFieldProps)}
350
602
  />
351
603
  {/if}
352
604
 
605
+ <!-- Credentials slot — replaces the credential block in identity-first flows.
606
+ Wrapped so it inherits the same bottom rhythm the fields have (the form is
607
+ a zero-gap flex column); the class is the hook for changing that. -->
608
+ {#if credentialsSlot}
609
+ <div class={unstyled ? undefined : "stuic-register-form-credentials"}>
610
+ {@render credentialsSlot({ formData, fieldError })}
611
+ </div>
612
+ {/if}
613
+
353
614
  <!-- Bottom-position extra fields (default) -->
354
- {#each bottomFields as cfg, i (cfg.name)}
615
+ {#each bottomFields as cfg (cfg.name)}
355
616
  <FieldInput
356
- bind:this={bottomFieldRefs[i]}
617
+ bind:this={extraFieldRefs[cfg.name]}
357
618
  value={extraValue(cfg)}
358
619
  oninput={(e: Event) =>
359
620
  setExtraValue(cfg, (e.currentTarget as HTMLInputElement).value)}
@@ -380,10 +641,15 @@
380
641
 
381
642
  <!-- CTA -->
382
643
  {#if submitButton}
383
- {@render submitButton({ isSubmitting, disabled: isSubmitting })}
644
+ {@render submitButton({ isSubmitting, disabled: isSubmitting || submitDisabled })}
384
645
  {:else}
385
646
  <div class={unstyled ? undefined : "stuic-register-form-submit"}>
386
- <Button intent="primary" type="submit" disabled={isSubmitting} class="w-full">
647
+ <Button
648
+ intent="primary"
649
+ type="submit"
650
+ disabled={isSubmitting || submitDisabled}
651
+ class="w-full"
652
+ >
387
653
  {isSubmitting
388
654
  ? (submittingLabel ?? t("register_form.submitting"))
389
655
  : (submitLabel ?? t("register_form.submit"))}
@@ -391,22 +657,9 @@
391
657
  </div>
392
658
  {/if}
393
659
 
394
- <!-- Social logins -->
395
- {#if socialLogins}
396
- <div class={unstyled ? undefined : "stuic-register-form-social"}>
397
- {#if socialDividerLabel !== false}
398
- <div class={unstyled ? undefined : "stuic-register-form-social-divider"}>
399
- <span>
400
- {typeof socialDividerLabel === "string"
401
- ? socialDividerLabel
402
- : t("register_form.social_divider")}
403
- </span>
404
- </div>
405
- {/if}
406
- <div class={unstyled ? undefined : "stuic-register-form-social-buttons"}>
407
- {@render socialLogins()}
408
- </div>
409
- </div>
660
+ <!-- Social logins below the CTA (default) -->
661
+ {#if socialPosition !== "top"}
662
+ {@render socialBlock("bottom")}
410
663
  {/if}
411
664
 
412
665
  <!-- Footer -->