@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
@@ -1,6 +1,7 @@
1
1
  import type { Snippet } from "svelte";
2
2
  import type { HTMLAttributes } from "svelte/elements";
3
3
  import type { TranslateFn } from "../../types.js";
4
+ import type { Props as FieldInputProps } from "../Input/FieldInput.svelte";
4
5
  import type { RegisterFieldConfig, RegisterFormData, RegisterFormValidationError } from "./_internal/register-form-types.js";
5
6
  import type { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
6
7
  export interface Props extends Omit<HTMLAttributes<HTMLFormElement>, "children"> {
@@ -10,6 +11,17 @@ export interface Props extends Omit<HTMLAttributes<HTMLFormElement>, "children">
10
11
  onSubmit: (data: RegisterFormData) => void;
11
12
  /** Whether the form is currently submitting (disables CTA) */
12
13
  isSubmitting?: boolean;
14
+ /**
15
+ * Consumer-owned reason to refuse submission which is neither "submitting"
16
+ * nor a field error — e.g. an external identity whose server-side TTL has
17
+ * lapsed, an unchecked terms box rendered in `extraFieldsSlot`, or an async
18
+ * availability check still in flight.
19
+ *
20
+ * Disables the default CTA, is reflected in the `submitButton` snippet
21
+ * payload, and also prevents `onSubmit` from firing at all — so a custom
22
+ * `submitButton` which ignores `disabled` still cannot post. Default: false
23
+ */
24
+ submitDisabled?: boolean;
13
25
  /** Field-specific validation errors (e.g., from server) */
14
26
  errors?: RegisterFormValidationError[];
15
27
  /**
@@ -18,10 +30,57 @@ export interface Props extends Omit<HTMLAttributes<HTMLFormElement>, "children">
18
30
  * Example: "Email already registered"
19
31
  */
20
32
  error?: string;
21
- /** Show password confirmation field. Default: true */
33
+ /**
34
+ * Render the built-in email field. When false the field is UNMOUNTED (not
35
+ * hidden) and skipped by validation — use it when the account identity is
36
+ * established elsewhere (OAuth provider, invite token, magic link) and pair
37
+ * it with `credentialsSlot`. Default: true
38
+ */
39
+ showEmail?: boolean;
40
+ /**
41
+ * Render the built-in password field. When false it (and, transitively, the
42
+ * confirm field) is UNMOUNTED and skipped by validation — e.g. passwordless
43
+ * / identity-first signup. Default: true
44
+ */
45
+ showPassword?: boolean;
46
+ /**
47
+ * Show password confirmation field. Subordinate to `showPassword`:
48
+ * `showPassword={false}` implies no confirm field regardless. Default: true
49
+ */
22
50
  showPasswordConfirm?: boolean;
23
51
  /** Minimum password length. Fed into FieldInput `minlength` + validator. Default: 8 */
24
52
  passwordMinLength?: number;
53
+ /**
54
+ * Rendered at the credentials position — after whatever core fields are
55
+ * still shown, before the bottom-position extra fields.
56
+ *
57
+ * Combined with `showEmail` / `showPassword` = false this REPLACES the
58
+ * credential block: use it for an identity established elsewhere ("Signed
59
+ * in as x@y.z — change / use a password instead"), an invite summary, or a
60
+ * magic-link explainer. Receives `formData` and a `fieldError(name)` lookup.
61
+ */
62
+ credentialsSlot?: Snippet<[
63
+ {
64
+ formData: RegisterFormData;
65
+ fieldError: (name: string) => string | undefined;
66
+ }
67
+ ]>;
68
+ /**
69
+ * Passthrough props merged onto the built-in email `FieldInput`, applied
70
+ * AFTER the component's own — so `label`, `placeholder`, `description`,
71
+ * `autocomplete`, `renderSize`, `classInput`… are overridable without
72
+ * supplying a full `t`.
73
+ *
74
+ * `validate` is composed rather than replaced: your `customValidator` runs
75
+ * when there is no server/internal error for the field, so the `errors`
76
+ * wiring can't be knocked out by accident (`validate: false` is ignored for
77
+ * the same reason). `value` is ignored — the field is bound to `formData`.
78
+ */
79
+ emailFieldProps?: Partial<FieldInputProps>;
80
+ /** Passthrough props for the built-in password field. See `emailFieldProps`. */
81
+ passwordFieldProps?: Partial<FieldInputProps>;
82
+ /** Passthrough props for the built-in confirm field. See `emailFieldProps`. */
83
+ passwordConfirmFieldProps?: Partial<FieldInputProps>;
25
84
  /**
26
85
  * Declarative extra fields rendered as FieldInput entries.
27
86
  * Values bind into `formData.extra[name]`.
@@ -54,8 +113,22 @@ export interface Props extends Omit<HTMLAttributes<HTMLFormElement>, "children">
54
113
  */
55
114
  socialLogins?: Snippet;
56
115
  /**
57
- * Override the divider label above social login buttons.
58
- * Default: i18n key "register_form.social_divider" ("or continue with").
116
+ * Where the social block sits relative to the credentials:
117
+ * - `"bottom"` (default) — after the CTA, divider ABOVE the buttons
118
+ * - `"top"` — between the top-position extra fields and the credentials,
119
+ * divider BELOW the buttons
120
+ *
121
+ * `"top"` suits identity-first signup, where the provider button is an
122
+ * alternative to the credentials only — fields required on both paths
123
+ * (workspace id, invite code…) stay above the choice. Default: "bottom"
124
+ */
125
+ socialPosition?: "top" | "bottom";
126
+ /**
127
+ * Override the divider label next to the social login buttons.
128
+ * Default: i18n key "register_form.social_divider" ("or continue with") at
129
+ * the bottom position, "register_form.social_divider_alt" ("or") at the top
130
+ * one — where the divider faces the credentials, so "or continue with"
131
+ * would read backwards.
59
132
  * Set to `false` to hide the divider while still rendering socialLogins.
60
133
  */
61
134
  socialDividerLabel?: string | false;
@@ -75,6 +148,7 @@ import { scrollToFirstInvalidField } from "../../utils/validate-fields.js";
75
148
  declare const RegisterForm: import("svelte").Component<Props, {
76
149
  validate: () => boolean;
77
150
  scrollToFirstError: (opts?: Parameters<typeof scrollToFirstInvalidField>[1]) => boolean;
151
+ focusField: (name: string) => boolean;
78
152
  }, "el" | "formData">;
79
153
  type RegisterForm = ReturnType<typeof RegisterForm>;
80
154
  export default RegisterForm;
@@ -7,6 +7,7 @@
7
7
  RegisterFormValidationError,
8
8
  } from "./_internal/register-form-types.js";
9
9
  import type { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
10
+ import type { Props as InnerProps } from "./RegisterForm.svelte";
10
11
 
11
12
  export interface Props {
12
13
  /** Bindable register data. Default: createEmptyRegisterFormData() */
@@ -18,6 +19,12 @@
18
19
  /** Whether the form is currently submitting (disables CTA) */
19
20
  isSubmitting?: boolean;
20
21
 
22
+ /**
23
+ * Consumer-owned reason to refuse submission (stale token, unchecked terms,
24
+ * async check in flight…). Also blocks `onSubmit`. Default: false
25
+ */
26
+ submitDisabled?: boolean;
27
+
21
28
  /** Field-specific validation errors (e.g., from server) */
22
29
  errors?: RegisterFormValidationError[];
23
30
 
@@ -27,12 +34,34 @@
27
34
  */
28
35
  error?: string;
29
36
 
37
+ /** Render the built-in email field (unmounted when false). Default: true */
38
+ showEmail?: boolean;
39
+
40
+ /** Render the built-in password field (unmounted when false). Default: true */
41
+ showPassword?: boolean;
42
+
30
43
  /** Show password confirmation field. Default: true */
31
44
  showPasswordConfirm?: boolean;
32
45
 
33
46
  /** Minimum password length. Default: 8 */
34
47
  passwordMinLength?: number;
35
48
 
49
+ /**
50
+ * Rendered at the credentials position. Combined with `showEmail` /
51
+ * `showPassword` = false it replaces the credential block (identity-first
52
+ * signup, invite summary, magic-link explainer).
53
+ */
54
+ credentialsSlot?: InnerProps["credentialsSlot"];
55
+
56
+ /** Passthrough props for the built-in email field. */
57
+ emailFieldProps?: InnerProps["emailFieldProps"];
58
+
59
+ /** Passthrough props for the built-in password field. */
60
+ passwordFieldProps?: InnerProps["passwordFieldProps"];
61
+
62
+ /** Passthrough props for the built-in confirm field. */
63
+ passwordConfirmFieldProps?: InnerProps["passwordConfirmFieldProps"];
64
+
36
65
  /** Declarative extra fields */
37
66
  extraFields?: RegisterFieldConfig[];
38
67
 
@@ -60,6 +89,12 @@
60
89
  */
61
90
  socialLogins?: Snippet;
62
91
 
92
+ /**
93
+ * Where the social block sits relative to the credentials.
94
+ * `"top"` renders it above them (divider below the buttons). Default: "bottom"
95
+ */
96
+ socialPosition?: "top" | "bottom";
97
+
63
98
  /**
64
99
  * Override the divider label above social login buttons.
65
100
  */
@@ -110,21 +145,30 @@
110
145
  import { createEmptyRegisterFormData } from "./_internal/register-form-utils.js";
111
146
  import { twMerge } from "../../utils/tw-merge.js";
112
147
  import H from "../H/H.svelte";
148
+ import type { scrollToFirstInvalidField } from "../../utils/validate-fields.js";
113
149
 
114
150
  let {
115
151
  formData = $bindable(createEmptyRegisterFormData()),
116
152
  onSubmit,
117
153
  isSubmitting = false,
154
+ submitDisabled,
118
155
  errors,
119
156
  error,
157
+ showEmail,
158
+ showPassword,
120
159
  showPasswordConfirm,
121
160
  passwordMinLength,
161
+ credentialsSlot,
162
+ emailFieldProps,
163
+ passwordFieldProps,
164
+ passwordConfirmFieldProps,
122
165
  extraFields,
123
166
  extraFieldsSlot,
124
167
  submitLabel,
125
168
  submittingLabel,
126
169
  submitButton,
127
170
  socialLogins,
171
+ socialPosition,
128
172
  socialDividerLabel,
129
173
  footer,
130
174
  notifications,
@@ -143,6 +187,7 @@
143
187
  let t = $derived(tProp ?? t_default);
144
188
 
145
189
  let modal: Modal = $state()!;
190
+ let form = $state<RegisterForm>();
146
191
 
147
192
  export function open(openerOrEvent?: null | HTMLElement | MouseEvent) {
148
193
  modal.open(openerOrEvent);
@@ -151,6 +196,30 @@
151
196
  export function close() {
152
197
  modal.close();
153
198
  }
199
+
200
+ // Same imperative API the inner form exposes, forwarded. The form only exists
201
+ // while the modal is open, so all three are no-ops when it is closed.
202
+
203
+ /**
204
+ * Run every field's validator. Returns true if all valid — including when the
205
+ * modal is closed and there is no form to ask, so check `visible` first if
206
+ * that distinction matters.
207
+ */
208
+ export function validate(): boolean {
209
+ return form?.validate() ?? true;
210
+ }
211
+
212
+ /** Scroll + focus the first invalid field. Call after `validate()`. */
213
+ export function scrollToFirstError(
214
+ opts?: Parameters<typeof scrollToFirstInvalidField>[1]
215
+ ): boolean {
216
+ return form?.scrollToFirstError(opts) ?? false;
217
+ }
218
+
219
+ /** Focus a field by name. Returns false if it is not currently rendered. */
220
+ export function focusField(name: string): boolean {
221
+ return form?.focusField(name) ?? false;
222
+ }
154
223
  </script>
155
224
 
156
225
  {#if trigger}
@@ -187,19 +256,28 @@
187
256
 
188
257
  <div class="p-6 pt-3">
189
258
  <RegisterForm
259
+ bind:this={form}
190
260
  bind:formData
191
261
  {onSubmit}
192
262
  {isSubmitting}
263
+ {submitDisabled}
193
264
  {errors}
194
265
  {error}
266
+ {showEmail}
267
+ {showPassword}
195
268
  {showPasswordConfirm}
196
269
  {passwordMinLength}
270
+ {credentialsSlot}
271
+ {emailFieldProps}
272
+ {passwordFieldProps}
273
+ {passwordConfirmFieldProps}
197
274
  {extraFields}
198
275
  {extraFieldsSlot}
199
276
  {submitLabel}
200
277
  {submittingLabel}
201
278
  {submitButton}
202
279
  {socialLogins}
280
+ {socialPosition}
203
281
  {socialDividerLabel}
204
282
  {footer}
205
283
  {notifications}
@@ -2,6 +2,7 @@ import type { Snippet } from "svelte";
2
2
  import type { TranslateFn } from "../../types.js";
3
3
  import type { RegisterFieldConfig, RegisterFormData, RegisterFormValidationError } from "./_internal/register-form-types.js";
4
4
  import type { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
5
+ import type { Props as InnerProps } from "./RegisterForm.svelte";
5
6
  export interface Props {
6
7
  /** Bindable register data. Default: createEmptyRegisterFormData() */
7
8
  formData?: RegisterFormData;
@@ -9,6 +10,11 @@ export interface Props {
9
10
  onSubmit: (data: RegisterFormData) => void;
10
11
  /** Whether the form is currently submitting (disables CTA) */
11
12
  isSubmitting?: boolean;
13
+ /**
14
+ * Consumer-owned reason to refuse submission (stale token, unchecked terms,
15
+ * async check in flight…). Also blocks `onSubmit`. Default: false
16
+ */
17
+ submitDisabled?: boolean;
12
18
  /** Field-specific validation errors (e.g., from server) */
13
19
  errors?: RegisterFormValidationError[];
14
20
  /**
@@ -16,10 +22,26 @@ export interface Props {
16
22
  * Rendered as an alert box above the form.
17
23
  */
18
24
  error?: string;
25
+ /** Render the built-in email field (unmounted when false). Default: true */
26
+ showEmail?: boolean;
27
+ /** Render the built-in password field (unmounted when false). Default: true */
28
+ showPassword?: boolean;
19
29
  /** Show password confirmation field. Default: true */
20
30
  showPasswordConfirm?: boolean;
21
31
  /** Minimum password length. Default: 8 */
22
32
  passwordMinLength?: number;
33
+ /**
34
+ * Rendered at the credentials position. Combined with `showEmail` /
35
+ * `showPassword` = false it replaces the credential block (identity-first
36
+ * signup, invite summary, magic-link explainer).
37
+ */
38
+ credentialsSlot?: InnerProps["credentialsSlot"];
39
+ /** Passthrough props for the built-in email field. */
40
+ emailFieldProps?: InnerProps["emailFieldProps"];
41
+ /** Passthrough props for the built-in password field. */
42
+ passwordFieldProps?: InnerProps["passwordFieldProps"];
43
+ /** Passthrough props for the built-in confirm field. */
44
+ passwordConfirmFieldProps?: InnerProps["passwordConfirmFieldProps"];
23
45
  /** Declarative extra fields */
24
46
  extraFields?: RegisterFieldConfig[];
25
47
  /** Escape-hatch slot for non-FieldInput extras */
@@ -42,6 +64,11 @@ export interface Props {
42
64
  * Social/OAuth login buttons rendered below the primary form.
43
65
  */
44
66
  socialLogins?: Snippet;
67
+ /**
68
+ * Where the social block sits relative to the credentials.
69
+ * `"top"` renders it above them (divider below the buttons). Default: "bottom"
70
+ */
71
+ socialPosition?: "top" | "bottom";
45
72
  /**
46
73
  * Override the divider label above social login buttons.
47
74
  */
@@ -74,9 +101,13 @@ export interface Props {
74
101
  noXClose?: boolean;
75
102
  onClose?: () => false | void;
76
103
  }
104
+ import type { scrollToFirstInvalidField } from "../../utils/validate-fields.js";
77
105
  declare const RegisterFormModal: import("svelte").Component<Props, {
78
106
  open: (openerOrEvent?: null | HTMLElement | MouseEvent) => void;
79
107
  close: () => void;
108
+ validate: () => boolean;
109
+ scrollToFirstError: (opts?: Parameters<typeof scrollToFirstInvalidField>[1]) => boolean;
110
+ focusField: (name: string) => boolean;
80
111
  }, "visible" | "formData">;
81
112
  type RegisterFormModal = ReturnType<typeof RegisterFormModal>;
82
113
  export default RegisterFormModal;
@@ -17,6 +17,7 @@ const DEFAULTS = {
17
17
  "register_form.password_mismatch": "Passwords do not match",
18
18
  "register_form.field_required": "{label} is required",
19
19
  "register_form.social_divider": "or continue with",
20
+ "register_form.social_divider_alt": "or",
20
21
  "register_form.already_have_account": "Already have an account?",
21
22
  "register_form.modal_title": "Create account",
22
23
  };
@@ -12,7 +12,11 @@ export interface RegisterFormValidationError {
12
12
  }
13
13
  /** Declarative descriptor for a consumer-defined FieldInput-style extra field. */
14
14
  export interface RegisterFieldConfig {
15
- /** Key under formData.extra where the value lives. Must be unique. */
15
+ /**
16
+ * Key under formData.extra where the value lives. Must be unique.
17
+ * `"email"`, `"password"` and `"passwordConfirm"` are reserved by the core
18
+ * fields — reusing one makes `focusField()` target the core field instead.
19
+ */
16
20
  name: string;
17
21
  /** Visible label (already translated; RegisterForm does not apply `t()` here). */
18
22
  label: string;
@@ -21,7 +25,13 @@ export interface RegisterFieldConfig {
21
25
  placeholder?: string;
22
26
  required?: boolean;
23
27
  autocomplete?: HTMLInputAttributes["autocomplete"];
24
- /** Initial value to seed into formData.extra[name] if undefined. */
28
+ /**
29
+ * Initial value seeded into `formData.extra[name]` when that key is null-ish.
30
+ * Seeded raw (your type is preserved), while user edits always write strings —
31
+ * so prefer a string here for FieldInput-rendered extras, otherwise a numeric
32
+ * seed reaches `validate` / `onSubmit` as a number before the first edit and
33
+ * as a string after it.
34
+ */
25
35
  initialValue?: unknown;
26
36
  /**
27
37
  * Synchronous validator. Return empty string / undefined for "valid".
@@ -1,7 +1,24 @@
1
1
  import type { RegisterFieldConfig, RegisterFormData, RegisterFormValidationError } from "./register-form-types.js";
2
2
  import type { TranslateFn } from "../../../types.js";
3
- export declare function validateRegisterForm(data: RegisterFormData, t: TranslateFn, extraFields?: RegisterFieldConfig[], options?: {
3
+ /** Options for {@link validateRegisterForm}. Mirror the same-named props. */
4
+ export interface ValidateRegisterFormOptions {
5
+ /** Validate the email field. Default: true */
6
+ showEmail?: boolean;
7
+ /** Validate the password field. Default: true */
8
+ showPassword?: boolean;
9
+ /** Validate the confirm field. Ignored when `showPassword` is false. Default: true */
4
10
  showPasswordConfirm?: boolean;
11
+ /** Minimum password length. Default: 8 */
5
12
  passwordMinLength?: number;
6
- }): RegisterFormValidationError[];
13
+ }
14
+ /**
15
+ * Validate a `RegisterFormData` shape. Exported so consumers running their own
16
+ * submit handler can reuse the exact same rules `RegisterForm` applies. `t`
17
+ * defaults to the built-in English strings.
18
+ *
19
+ * `options.showEmail` / `options.showPassword` mirror the same-named props:
20
+ * a field that is not rendered is not validated (identity-first / passwordless
21
+ * signup, where the credential is established outside the form).
22
+ */
23
+ export declare function validateRegisterForm(data: RegisterFormData, t?: TranslateFn, extraFields?: RegisterFieldConfig[], options?: ValidateRegisterFormOptions): RegisterFormValidationError[];
7
24
  export declare function createEmptyRegisterFormData(): RegisterFormData;
@@ -1,24 +1,41 @@
1
+ import { t_default } from "./register-form-i18n-defaults.js";
1
2
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2
- export function validateRegisterForm(data, t, extraFields = [], options = {}) {
3
- const { showPasswordConfirm = true, passwordMinLength = 8 } = options;
3
+ /**
4
+ * Validate a `RegisterFormData` shape. Exported so consumers running their own
5
+ * submit handler can reuse the exact same rules `RegisterForm` applies. `t`
6
+ * defaults to the built-in English strings.
7
+ *
8
+ * `options.showEmail` / `options.showPassword` mirror the same-named props:
9
+ * a field that is not rendered is not validated (identity-first / passwordless
10
+ * signup, where the credential is established outside the form).
11
+ */
12
+ export function validateRegisterForm(data, t = t_default, extraFields = [], options = {}) {
13
+ const { showEmail = true, showPassword = true, showPasswordConfirm = true, passwordMinLength = 8, } = options;
4
14
  const errors = [];
5
- const trimmedEmail = data.email.trim();
6
- if (!trimmedEmail) {
7
- errors.push({ field: "email", message: t("register_form.email_required") });
8
- }
9
- else if (!EMAIL_RE.test(trimmedEmail)) {
10
- errors.push({ field: "email", message: t("register_form.email_invalid") });
11
- }
12
- if (!data.password) {
13
- errors.push({ field: "password", message: t("register_form.password_required") });
15
+ if (showEmail) {
16
+ const trimmedEmail = (data.email ?? "").trim();
17
+ if (!trimmedEmail) {
18
+ errors.push({ field: "email", message: t("register_form.email_required") });
19
+ }
20
+ else if (!EMAIL_RE.test(trimmedEmail)) {
21
+ errors.push({ field: "email", message: t("register_form.email_invalid") });
22
+ }
14
23
  }
15
- else if (data.password.length < passwordMinLength) {
16
- errors.push({
17
- field: "password",
18
- message: t("register_form.password_too_short", { min: passwordMinLength }),
19
- });
24
+ if (showPassword) {
25
+ if (!data.password) {
26
+ errors.push({
27
+ field: "password",
28
+ message: t("register_form.password_required"),
29
+ });
30
+ }
31
+ else if (data.password.length < passwordMinLength) {
32
+ errors.push({
33
+ field: "password",
34
+ message: t("register_form.password_too_short", { min: passwordMinLength }),
35
+ });
36
+ }
20
37
  }
21
- if (showPasswordConfirm) {
38
+ if (showPassword && showPasswordConfirm) {
22
39
  if (!data.passwordConfirm) {
23
40
  errors.push({
24
41
  field: "passwordConfirm",
@@ -10,6 +10,14 @@
10
10
  --stuic-register-form-social-divider-line-color: var(--stuic-color-border);
11
11
  --stuic-register-form-social-divider-font-size: var(--text-sm);
12
12
  --stuic-register-form-social-divider-margin-bottom: 0.75rem;
13
+
14
+ /* Social login section when placed above the credentials (socialPosition="top") */
15
+ --stuic-register-form-social-margin-bottom: 1rem;
16
+ --stuic-register-form-social-divider-margin-top: 0.75rem;
17
+
18
+ /* credentialsSlot wrapper + the per-field rhythm inside the form */
19
+ --stuic-register-form-credentials-margin-bottom: 1rem;
20
+ --stuic-register-form-field-margin-bottom: 1rem;
13
21
  }
14
22
 
15
23
  @layer components {
@@ -24,6 +32,13 @@
24
32
  margin-bottom: 1.5rem;
25
33
  }
26
34
 
35
+ /* credentialsSlot wrapper — the form is a zero-gap flex column, so consumer
36
+ content dropped in at the credentials position needs the same bottom
37
+ rhythm the fields get. */
38
+ .stuic-register-form-credentials {
39
+ margin-bottom: var(--stuic-register-form-credentials-margin-bottom);
40
+ }
41
+
27
42
  /* Social login container */
28
43
  .stuic-register-form-social {
29
44
  margin-top: var(--stuic-register-form-social-margin-top);
@@ -52,9 +67,25 @@
52
67
  flex-direction: column;
53
68
  gap: var(--stuic-register-form-social-gap);
54
69
  }
70
+
71
+ /* socialPosition="top" — the block sits above the credentials, so the margin
72
+ and the divider both mirror. `[data-position="bottom"]` is intentionally
73
+ unstyled: the default render stays byte-for-byte what it always was. */
74
+ .stuic-register-form-social[data-position="top"] {
75
+ margin-top: 0;
76
+ margin-bottom: var(--stuic-register-form-social-margin-bottom);
77
+ }
78
+
79
+ .stuic-register-form-social[data-position="top"] .stuic-register-form-social-divider {
80
+ margin-top: var(--stuic-register-form-social-divider-margin-top);
81
+ margin-bottom: 0;
82
+ }
55
83
  }
56
84
 
57
- /* Tighten default FieldInput bottom margin (mb-8 → 1rem) inside the form */
85
+ /* Tighten default FieldInput bottom margin (mb-8 → 1rem) inside the form.
86
+ Deliberately unlayered so it outranks the utility class it replaces — which
87
+ also means a `class` passed through `emailFieldProps` can't override it, so
88
+ the value is a token instead of a literal. */
58
89
  .stuic-register-form .stuic-input {
59
- margin-bottom: 1rem;
90
+ margin-bottom: var(--stuic-register-form-field-margin-bottom);
60
91
  }
@@ -1,4 +1,5 @@
1
1
  export { default as RegisterForm, type Props as RegisterFormProps, } from "./RegisterForm.svelte";
2
2
  export { default as RegisterFormModal, type Props as RegisterFormModalProps, } from "./RegisterFormModal.svelte";
3
- export { createEmptyRegisterFormData } from "./_internal/register-form-utils.js";
3
+ export { createEmptyRegisterFormData, validateRegisterForm, } from "./_internal/register-form-utils.js";
4
+ export type { ValidateRegisterFormOptions } from "./_internal/register-form-utils.js";
4
5
  export type { RegisterFormData, RegisterFormValidationError, RegisterFieldConfig, } from "./_internal/register-form-types.js";
@@ -1,3 +1,3 @@
1
1
  export { default as RegisterForm, } from "./RegisterForm.svelte";
2
2
  export { default as RegisterFormModal, } from "./RegisterFormModal.svelte";
3
- export { createEmptyRegisterFormData } from "./_internal/register-form-utils.js";
3
+ export { createEmptyRegisterFormData, validateRegisterForm, } from "./_internal/register-form-utils.js";
@@ -0,0 +1,114 @@
1
+ import type { ValidatableField } from "./validate-fields.js";
2
+ /**
3
+ * Minimal shape of a `errors` entry — every STUIC form's `*ValidationError`
4
+ * type structurally satisfies it.
5
+ */
6
+ export interface FieldError {
7
+ field: string;
8
+ message: string;
9
+ }
10
+ export interface ExternalFieldErrorsOptions<E extends FieldError> {
11
+ /** The consumer-supplied `errors` prop. Read reactively — pass a getter. */
12
+ errors: () => E[];
13
+ /**
14
+ * Is this error's field currently rendered — i.e. is there something on
15
+ * screen the user could edit to answer it?
16
+ *
17
+ * Fields that are not (a field the consumer renders itself from a slot, or
18
+ * one switched off by a `show*` prop) keep the original behavior: they apply
19
+ * until the consumer drops them from `errors`. Auto-clearing those would let
20
+ * the form post past a block the consumer set deliberately — an unchecked
21
+ * terms box, a failed captcha, a pending availability check.
22
+ */
23
+ isRendered: (field: string) => boolean;
24
+ /** Comparable current value of a rendered field. */
25
+ valueOf: (field: string) => string;
26
+ }
27
+ export interface ExternalFieldErrors<E extends FieldError> {
28
+ /** The subset of `errors` that still applies — stale entries filtered out. */
29
+ readonly live: E[];
30
+ /**
31
+ * Mark that a server round-trip is starting. Call it wherever the form hands
32
+ * data to the consumer (its `onSubmit`, and its exported `validate()` for
33
+ * consumers posting from their own handler).
34
+ */
35
+ markSubmitted(): void;
36
+ }
37
+ /**
38
+ * Give a form's consumer-supplied `errors` prop a lifecycle.
39
+ *
40
+ * `errors` is consumer-owned: a form can render it but cannot clear it. Taken
41
+ * literally that wedges the form permanently — the field's `customValidator`
42
+ * keeps reporting the server error whatever the user types, so
43
+ * `onSubmitValidityCheck` routes every later submit to `submit_invalid` and
44
+ * `onSubmit` never fires again, *including* the consumer's own handler that
45
+ * would have cleared the errors. (Forms without that action hit the same wall
46
+ * through their `externalErrors.length === 0` submit gate.)
47
+ *
48
+ * So: remember the value each field held when a set of errors was delivered.
49
+ * An error on a rendered field stays live while that field still holds that
50
+ * value and goes stale once the user edits it — dropped from the inline
51
+ * messages and from the submit gate, the same lifecycle internal validation
52
+ * errors already have. Typing the rejected value back in makes it live again,
53
+ * which is correct: that exact value is known-bad.
54
+ *
55
+ * Two details that look like over-engineering and are not:
56
+ *
57
+ * - Delivery is detected by the errors' **content**, never by array identity.
58
+ * Consumers routinely pass an inline literal or a freshly derived array whose
59
+ * identity changes on every parent re-render; re-snapshotting mid-typing would
60
+ * resurrect the error and restore the deadlock.
61
+ * - Because of that, an identical error redelivered after a resubmit would look
62
+ * like the previous, already-answered one. `markSubmitted()` disambiguates:
63
+ * the next delivery after it counts as fresh even when byte-identical.
64
+ *
65
+ * Must be called during component initialization (it registers an `$effect`).
66
+ *
67
+ * @example
68
+ * ```svelte
69
+ * <script>
70
+ * let { errors: externalErrors = [], formData = $bindable(...) } = $props();
71
+ *
72
+ * const external = createExternalFieldErrors({
73
+ * errors: () => externalErrors,
74
+ * isRendered: (f) => f === "email" || f === "password",
75
+ * valueOf: (f) => (f === "email" ? formData.email : formData.password) ?? "",
76
+ * });
77
+ *
78
+ * function handleSubmitValid() {
79
+ * // ...
80
+ * if (!validationErrors.length && !external.live.length) {
81
+ * external.markSubmitted();
82
+ * onSubmit(formData);
83
+ * }
84
+ * }
85
+ * </script>
86
+ * ```
87
+ */
88
+ export declare function createExternalFieldErrors<E extends FieldError>(options: ExternalFieldErrorsOptions<E>): ExternalFieldErrors<E>;
89
+ /**
90
+ * Render a field's error message as soon as it exists, instead of waiting for
91
+ * the user's next interaction.
92
+ *
93
+ * Inline messages come from each field's own validation run, and the `validate`
94
+ * action fires on `change` / first blur. An error that appears *after* the
95
+ * validators last ran therefore paints nothing:
96
+ *
97
+ * - a form's own validator result is computed on `submit_valid`, i.e. after
98
+ * `onSubmitValidityCheck` has already re-run every field's validator, so a
99
+ * rule with no DOM equivalent (a password mismatch, a custom validator, an
100
+ * email that satisfies `type="email"` but not a stricter regex) produced a
101
+ * completely silent first click — the CTA appeared to do nothing;
102
+ * - a server `errors` delivery lands once the submit is over, so it stayed
103
+ * invisible until the user clicked again.
104
+ *
105
+ * Only fields gaining an error are re-validated; clearing is left to the field's
106
+ * own next change/blur, so fixing one field doesn't wipe the messages still
107
+ * standing on its siblings.
108
+ *
109
+ * Must be called during component initialization (it registers an `$effect`).
110
+ *
111
+ * @param errors - all currently applicable errors (internal + live external)
112
+ * @param fieldByName - resolves a field name to its rendered component, if any
113
+ */
114
+ export declare function repaintFieldErrors(errors: () => FieldError[], fieldByName: (field: string) => ValidatableField | undefined): void;