@marianmeres/stuic 3.70.1 → 3.71.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 (26) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +1 -1
  3. package/dist/components/EmailVerifyForm/EmailVerifyForm.svelte +269 -0
  4. package/dist/components/EmailVerifyForm/EmailVerifyForm.svelte.d.ts +47 -0
  5. package/dist/components/EmailVerifyForm/README.md +76 -0
  6. package/dist/components/EmailVerifyForm/_internal/email-verify-form-i18n-defaults.d.ts +1 -0
  7. package/dist/components/EmailVerifyForm/_internal/email-verify-form-i18n-defaults.js +21 -0
  8. package/dist/components/EmailVerifyForm/index.css +54 -0
  9. package/dist/components/EmailVerifyForm/index.d.ts +1 -0
  10. package/dist/components/EmailVerifyForm/index.js +1 -0
  11. package/dist/components/LoginOrRegisterForm/LoginOrRegisterForm.svelte +70 -21
  12. package/dist/components/LoginOrRegisterForm/LoginOrRegisterForm.svelte.d.ts +16 -2
  13. package/dist/components/LoginOrRegisterForm/LoginOrRegisterFormModal.svelte +26 -3
  14. package/dist/components/LoginOrRegisterForm/LoginOrRegisterFormModal.svelte.d.ts +9 -1
  15. package/dist/components/LoginOrRegisterForm/_internal/login-or-register-form-i18n-defaults.js +2 -0
  16. package/dist/components/OtpInput/OtpInput.svelte +0 -0
  17. package/dist/components/OtpInput/OtpInput.svelte.d.ts +30 -0
  18. package/dist/components/OtpInput/README.md +55 -0
  19. package/dist/components/OtpInput/index.css +41 -0
  20. package/dist/components/OtpInput/index.d.ts +1 -0
  21. package/dist/components/OtpInput/index.js +1 -0
  22. package/dist/index.css +2 -0
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +2 -0
  25. package/docs/domains/components.md +204 -1
  26. package/package.json +1 -1
package/AGENTS.md CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  ```
25
25
  src/lib/
26
- ├── components/ # 50 UI components
26
+ ├── components/ # 55 UI components
27
27
  ├── actions/ # 15 Svelte actions
28
28
  ├── utils/ # 43 utility modules
29
29
  ├── themes/ # Generated theme CSS (css/) — definitions from @marianmeres/design-tokens
@@ -116,7 +116,7 @@ Global tokens that control cross-component visual properties. Defined in `src/li
116
116
 
117
117
  ### Domain Docs
118
118
 
119
- - [Components](./docs/domains/components.md) — 46 component directories, Props pattern, snippets
119
+ - [Components](./docs/domains/components.md) — 55 component directories, Props pattern, snippets
120
120
  - [Theming](./docs/domains/theming.md) — CSS tokens, dark mode, themes
121
121
  - [Actions](./docs/domains/actions.md) — 15 Svelte directives
122
122
  - [Utils](./docs/domains/utils.md) — 43 utility modules
package/README.md CHANGED
@@ -148,7 +148,7 @@ AppShell, Accordion, Backdrop, Modal, ModalDialog, Drawer, Collapsible, Header,
148
148
 
149
149
  ### Forms & Inputs
150
150
 
151
- FieldInput, FieldTextarea, FieldSelect, FieldCheckbox, FieldRadios, FieldFile, FieldAssets, FieldOptions, FieldKeyValues, FieldObject, FieldSwitch, FieldInputLocalized, FieldLikeButton, FieldPhoneNumber, CronInput, Fieldset, LoginForm, LoginFormModal
151
+ FieldInput, FieldTextarea, FieldSelect, FieldCheckbox, FieldRadios, FieldFile, FieldAssets, FieldOptions, FieldKeyValues, FieldObject, FieldSwitch, FieldInputLocalized, FieldLikeButton, FieldPhoneNumber, CronInput, Fieldset, LoginForm, LoginFormModal, RegisterForm, LoginOrRegisterForm, LoginOrRegisterFormModal, EmailVerifyForm, OtpInput
152
152
 
153
153
  ### Buttons & Controls
154
154
 
@@ -0,0 +1,269 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import type { TranslateFn } from "../../types.js";
5
+ import type { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
6
+ import type { Props as OtpInputProps } from "../OtpInput/OtpInput.svelte";
7
+
8
+ export interface Props extends Omit<HTMLAttributes<HTMLFormElement>, "children"> {
9
+ /** Email address the code was sent to. Displayed in the subhead. */
10
+ email: string;
11
+
12
+ /** Called when the user submits a code (manually or via OtpInput auto-complete). */
13
+ onSubmit: (code: string) => void;
14
+
15
+ /**
16
+ * Called when the user clicks "Resend code". When provided, the resend control
17
+ * renders inside the form. The form owns the cooldown UI internally.
18
+ */
19
+ onResend?: () => Promise<void> | void;
20
+
21
+ /**
22
+ * Cooldown (seconds) after a successful resend, during which the resend
23
+ * link is disabled and shows a countdown. Default: 30.
24
+ */
25
+ resendCooldownSeconds?: number;
26
+
27
+ /** Disables submit button + OtpInput while submitting. */
28
+ isSubmitting?: boolean;
29
+
30
+ /** General error (rendered as a DismissibleMessage above the form). */
31
+ error?: string;
32
+
33
+ /** Optional: shown inline, e.g., "3 attempts remaining". Set to undefined to hide. */
34
+ attemptsRemaining?: number;
35
+
36
+ /** Code length. Default: 6. Forwarded to OtpInput. */
37
+ codeLength?: number;
38
+
39
+ /** Pass-through props for the inner OtpInput (spread). */
40
+ otpInputProps?: Omit<
41
+ OtpInputProps,
42
+ "value" | "length" | "onComplete" | "error" | "disabled"
43
+ >;
44
+
45
+ /** Optional notifications instance — `error()` is called when `error` is set. */
46
+ notifications?: NotificationsStack;
47
+
48
+ /** Footer snippet (e.g., "Wrong email? Start over"). */
49
+ footer?: Snippet;
50
+
51
+ /** Override CTA section. */
52
+ submitButton?: Snippet<[{ isSubmitting: boolean; disabled: boolean }]>;
53
+
54
+ t?: TranslateFn;
55
+ unstyled?: boolean;
56
+ class?: string;
57
+ el?: HTMLFormElement;
58
+ }
59
+ </script>
60
+
61
+ <script lang="ts">
62
+ import { onDestroy } from "svelte";
63
+ import { twMerge } from "../../utils/tw-merge.js";
64
+ import { t_default } from "./_internal/email-verify-form-i18n-defaults.js";
65
+ import Button from "../Button/Button.svelte";
66
+ import DismissibleMessage from "../DismissibleMessage/DismissibleMessage.svelte";
67
+ import H from "../H/H.svelte";
68
+ import OtpInput from "../OtpInput/OtpInput.svelte";
69
+
70
+ let {
71
+ email,
72
+ onSubmit,
73
+ onResend,
74
+ resendCooldownSeconds = 30,
75
+ isSubmitting = false,
76
+ error,
77
+ attemptsRemaining,
78
+ codeLength = 6,
79
+ otpInputProps,
80
+ notifications,
81
+ footer,
82
+ submitButton,
83
+ t: tProp,
84
+ unstyled = false,
85
+ class: classProp,
86
+ el = $bindable(),
87
+ ...rest
88
+ }: Props = $props();
89
+
90
+ let t = $derived(tProp ?? t_default);
91
+
92
+ let code = $state("");
93
+ let cooldownRemaining = $state(0);
94
+ let resentFlash = $state(false);
95
+ let isResending = $state(false);
96
+ let cooldownTimer: ReturnType<typeof setInterval> | null = null;
97
+ let resentFlashTimer: ReturnType<typeof setTimeout> | null = null;
98
+
99
+ function clearTimers() {
100
+ if (cooldownTimer) {
101
+ clearInterval(cooldownTimer);
102
+ cooldownTimer = null;
103
+ }
104
+ if (resentFlashTimer) {
105
+ clearTimeout(resentFlashTimer);
106
+ resentFlashTimer = null;
107
+ }
108
+ }
109
+
110
+ onDestroy(clearTimers);
111
+
112
+ function escapeHtml(s: string): string {
113
+ return s.replace(/[&<>"']/g, (c) =>
114
+ c === "&"
115
+ ? "&amp;"
116
+ : c === "<"
117
+ ? "&lt;"
118
+ : c === ">"
119
+ ? "&gt;"
120
+ : c === '"'
121
+ ? "&quot;"
122
+ : "&#39;"
123
+ );
124
+ }
125
+
126
+ let subheadingHtml = $derived(
127
+ t("email_verify_form.subheading", {
128
+ email: `<strong>${escapeHtml(email ?? "")}</strong>`,
129
+ })
130
+ );
131
+
132
+ function startCooldown() {
133
+ cooldownRemaining = resendCooldownSeconds;
134
+ if (cooldownTimer) clearInterval(cooldownTimer);
135
+ cooldownTimer = setInterval(() => {
136
+ cooldownRemaining -= 1;
137
+ if (cooldownRemaining <= 0) {
138
+ cooldownRemaining = 0;
139
+ if (cooldownTimer) {
140
+ clearInterval(cooldownTimer);
141
+ cooldownTimer = null;
142
+ }
143
+ }
144
+ }, 1000);
145
+ }
146
+
147
+ function flashResent() {
148
+ resentFlash = true;
149
+ if (resentFlashTimer) clearTimeout(resentFlashTimer);
150
+ resentFlashTimer = setTimeout(() => {
151
+ resentFlash = false;
152
+ resentFlashTimer = null;
153
+ }, 3000);
154
+ }
155
+
156
+ async function handleResendClick() {
157
+ if (!onResend) return;
158
+ if (cooldownRemaining > 0 || isResending) return;
159
+ isResending = true;
160
+ try {
161
+ await onResend();
162
+ flashResent();
163
+ startCooldown();
164
+ } finally {
165
+ isResending = false;
166
+ }
167
+ }
168
+
169
+ function handleFormSubmit(e: SubmitEvent) {
170
+ e.preventDefault();
171
+ if (isSubmitting) return;
172
+ if (code.length !== codeLength) return;
173
+ onSubmit(code);
174
+ }
175
+
176
+ $effect(() => {
177
+ if (error && notifications) notifications.error(error);
178
+ });
179
+
180
+ let _class = $derived(
181
+ unstyled ? classProp : twMerge("stuic-email-verify-form", classProp)
182
+ );
183
+
184
+ let submitDisabled = $derived(code.length !== codeLength || isSubmitting);
185
+ </script>
186
+
187
+ <form bind:this={el} class={_class} onsubmit={handleFormSubmit} {...rest}>
188
+ <!-- Heading -->
189
+ <H level={2} class={unstyled ? undefined : "stuic-email-verify-form-heading"}>
190
+ {t("email_verify_form.heading")}
191
+ </H>
192
+
193
+ <!-- Subheading with bolded email -->
194
+ <p class={unstyled ? undefined : "stuic-email-verify-form-subheading"}>
195
+ <!-- email is HTML-escaped before substitution; the surrounding template is i18n-controlled -->
196
+ {@html subheadingHtml}
197
+ </p>
198
+
199
+ <!-- General error alert -->
200
+ <DismissibleMessage message={error} intent="destructive" onDismiss={false} />
201
+
202
+ <!-- OTP input -->
203
+ <div class={unstyled ? undefined : "stuic-email-verify-form-otp"}>
204
+ <OtpInput
205
+ bind:value={code}
206
+ length={codeLength}
207
+ onComplete={(c) => onSubmit(c)}
208
+ error={!!error}
209
+ disabled={isSubmitting}
210
+ {...otpInputProps}
211
+ />
212
+ </div>
213
+
214
+ <!-- Attempts remaining hint -->
215
+ {#if attemptsRemaining != null}
216
+ <small class={unstyled ? undefined : "stuic-email-verify-form-attempts"}>
217
+ {t("email_verify_form.attempts_remaining", { count: attemptsRemaining })}
218
+ </small>
219
+ {/if}
220
+
221
+ <!-- Submit button -->
222
+ {#if submitButton}
223
+ {@render submitButton({ isSubmitting, disabled: submitDisabled })}
224
+ {:else}
225
+ <div class={unstyled ? undefined : "stuic-email-verify-form-submit"}>
226
+ <Button
227
+ intent="primary"
228
+ type="submit"
229
+ disabled={submitDisabled}
230
+ class="w-full"
231
+ >
232
+ {isSubmitting
233
+ ? t("email_verify_form.submitting")
234
+ : t("email_verify_form.submit")}
235
+ </Button>
236
+ </div>
237
+ {/if}
238
+
239
+ <!-- Resend control -->
240
+ {#if onResend}
241
+ <div class={unstyled ? undefined : "stuic-email-verify-form-resend"}>
242
+ {#if cooldownRemaining > 0}
243
+ <span class={unstyled ? undefined : "stuic-email-verify-form-resend-cooldown"}>
244
+ {t("email_verify_form.resend_cooldown", { seconds: cooldownRemaining })}
245
+ </span>
246
+ {:else if resentFlash}
247
+ <span class={unstyled ? undefined : "stuic-email-verify-form-resend-flash"}>
248
+ {t("email_verify_form.resent")}
249
+ </span>
250
+ {:else}
251
+ <span>{t("email_verify_form.resend_prompt")}</span>
252
+ <Button
253
+ variant="link"
254
+ type="button"
255
+ size="sm"
256
+ onclick={handleResendClick}
257
+ disabled={isResending}
258
+ >
259
+ {t("email_verify_form.resend")}
260
+ </Button>
261
+ {/if}
262
+ </div>
263
+ {/if}
264
+
265
+ <!-- Footer -->
266
+ {#if footer}
267
+ {@render footer()}
268
+ {/if}
269
+ </form>
@@ -0,0 +1,47 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { TranslateFn } from "../../types.js";
4
+ import type { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
5
+ import type { Props as OtpInputProps } from "../OtpInput/OtpInput.svelte";
6
+ export interface Props extends Omit<HTMLAttributes<HTMLFormElement>, "children"> {
7
+ /** Email address the code was sent to. Displayed in the subhead. */
8
+ email: string;
9
+ /** Called when the user submits a code (manually or via OtpInput auto-complete). */
10
+ onSubmit: (code: string) => void;
11
+ /**
12
+ * Called when the user clicks "Resend code". When provided, the resend control
13
+ * renders inside the form. The form owns the cooldown UI internally.
14
+ */
15
+ onResend?: () => Promise<void> | void;
16
+ /**
17
+ * Cooldown (seconds) after a successful resend, during which the resend
18
+ * link is disabled and shows a countdown. Default: 30.
19
+ */
20
+ resendCooldownSeconds?: number;
21
+ /** Disables submit button + OtpInput while submitting. */
22
+ isSubmitting?: boolean;
23
+ /** General error (rendered as a DismissibleMessage above the form). */
24
+ error?: string;
25
+ /** Optional: shown inline, e.g., "3 attempts remaining". Set to undefined to hide. */
26
+ attemptsRemaining?: number;
27
+ /** Code length. Default: 6. Forwarded to OtpInput. */
28
+ codeLength?: number;
29
+ /** Pass-through props for the inner OtpInput (spread). */
30
+ otpInputProps?: Omit<OtpInputProps, "value" | "length" | "onComplete" | "error" | "disabled">;
31
+ /** Optional notifications instance — `error()` is called when `error` is set. */
32
+ notifications?: NotificationsStack;
33
+ /** Footer snippet (e.g., "Wrong email? Start over"). */
34
+ footer?: Snippet;
35
+ /** Override CTA section. */
36
+ submitButton?: Snippet<[{
37
+ isSubmitting: boolean;
38
+ disabled: boolean;
39
+ }]>;
40
+ t?: TranslateFn;
41
+ unstyled?: boolean;
42
+ class?: string;
43
+ el?: HTMLFormElement;
44
+ }
45
+ declare const EmailVerifyForm: import("svelte").Component<Props, {}, "el">;
46
+ type EmailVerifyForm = ReturnType<typeof EmailVerifyForm>;
47
+ export default EmailVerifyForm;
@@ -0,0 +1,76 @@
1
+ # EmailVerifyForm
2
+
3
+ "Check your email" form used after registration. Drop-in peer to `LoginForm` / `RegisterForm` — same prop conventions (`onSubmit`, `isSubmitting`, `error`, `notifications`, `t`, `unstyled`, `class`).
4
+
5
+ The form owns:
6
+
7
+ - A heading + subhead that displays the email the code was sent to.
8
+ - An `OtpInput` (default 6 digits) that auto-submits on completion.
9
+ - A general error banner via `DismissibleMessage`.
10
+ - An optional inline "attempts remaining" hint.
11
+ - An optional "Resend code" control with built-in cooldown countdown.
12
+
13
+ ## Usage
14
+
15
+ ```svelte
16
+ <script lang="ts">
17
+ import { EmailVerifyForm } from "@marianmeres/stuic";
18
+
19
+ async function handleVerify(code: string) {
20
+ // POST /api/verify with { email, code }
21
+ }
22
+
23
+ async function handleResend() {
24
+ // POST /api/resend-code with { email }
25
+ }
26
+ </script>
27
+
28
+ <EmailVerifyForm
29
+ email="user@example.com"
30
+ onSubmit={handleVerify}
31
+ onResend={handleResend}
32
+ resendCooldownSeconds={30}
33
+ error={errorMessage}
34
+ />
35
+ ```
36
+
37
+ ## Props
38
+
39
+ | Prop | Type | Default | Description |
40
+ | ----------------------- | ------------------------------------------ | -------- | -------------------------------------------------------- |
41
+ | `email` | `string` | required | Email address shown in the subhead |
42
+ | `onSubmit` | `(code: string) => void` | required | Called with the entered code (auto on complete + manual) |
43
+ | `onResend` | `() => Promise<void> \| void` | — | When set, renders a resend control |
44
+ | `resendCooldownSeconds` | `number` | `30` | Cooldown after a successful resend |
45
+ | `isSubmitting` | `boolean` | `false` | Disables submit + OtpInput |
46
+ | `error` | `string` | — | General error (renders alert + applies error styling) |
47
+ | `attemptsRemaining` | `number` | — | Inline hint, e.g. "3 attempts remaining" |
48
+ | `codeLength` | `number` | `6` | Forwarded to OtpInput |
49
+ | `otpInputProps` | `Partial<OtpInputProps>` | — | Pass-through props for the inner OtpInput |
50
+ | `notifications` | `NotificationsStack` | — | Route errors to notification system |
51
+ | `submitButton` | `Snippet` | — | Override submit section |
52
+ | `footer` | `Snippet` | — | Content below the resend control |
53
+ | `t` | `TranslateFn` | built-in | Translation function |
54
+ | `unstyled` | `boolean` | `false` | Skip default styling |
55
+ | `class` | `string` | — | Additional CSS classes on the form root |
56
+ | `el` | `HTMLFormElement` | — | Bindable form element |
57
+
58
+ ## i18n keys
59
+
60
+ | Key | Default |
61
+ | ----------------------------------------- | ------------------------------------------ |
62
+ | `email_verify_form.heading` | `Check your email` |
63
+ | `email_verify_form.subheading` | `We sent a 6-digit code to {email}` |
64
+ | `email_verify_form.submit` | `Verify` |
65
+ | `email_verify_form.submitting` | `Verifying...` |
66
+ | `email_verify_form.resend_prompt` | `Didn't receive it?` |
67
+ | `email_verify_form.resend` | `Resend code` |
68
+ | `email_verify_form.resend_cooldown` | `Resend available in {seconds}s` |
69
+ | `email_verify_form.resent` | `New code sent` |
70
+ | `email_verify_form.attempts_remaining` | `{count} attempts remaining` |
71
+
72
+ ## CSS Tokens
73
+
74
+ Prefix: `--stuic-email-verify-form-*`
75
+
76
+ `gap`, `subheading-color`, `attempts-color`, `resend-color`, `resend-flash-color`
@@ -0,0 +1 @@
1
+ export declare function t_default(k: string, values?: false | null | undefined | Record<string, string | number>, fallback?: string | boolean): string;
@@ -0,0 +1,21 @@
1
+ import { isPlainObject } from "../../../utils/is-plain-object.js";
2
+ import { replaceMap } from "../../../utils/replace-map.js";
3
+ const DEFAULTS = {
4
+ "email_verify_form.heading": "Check your email",
5
+ "email_verify_form.subheading": "We sent a 6-digit code to {email}",
6
+ "email_verify_form.submit": "Verify",
7
+ "email_verify_form.submitting": "Verifying...",
8
+ "email_verify_form.resend_prompt": "Didn't receive it?",
9
+ "email_verify_form.resend": "Resend code",
10
+ "email_verify_form.resend_cooldown": "Resend available in {seconds}s",
11
+ "email_verify_form.resent": "New code sent",
12
+ "email_verify_form.attempts_remaining": "{count} attempts remaining",
13
+ };
14
+ export function t_default(k, values = null, fallback = "") {
15
+ const out = DEFAULTS[k] ?? (typeof fallback === "string" ? fallback : "") ?? k;
16
+ return isPlainObject(values)
17
+ ? replaceMap(out, values, {
18
+ preSearchKeyTransform: (k) => `{${k}}`,
19
+ })
20
+ : out;
21
+ }
@@ -0,0 +1,54 @@
1
+ :root {
2
+ --stuic-email-verify-form-gap: 1rem;
3
+ --stuic-email-verify-form-subheading-color: var(--stuic-color-muted-foreground);
4
+ --stuic-email-verify-form-attempts-color: var(--stuic-color-muted-foreground);
5
+ --stuic-email-verify-form-resend-color: var(--stuic-color-muted-foreground);
6
+ --stuic-email-verify-form-resend-flash-color: var(--stuic-color-primary);
7
+ }
8
+
9
+ @layer components {
10
+ .stuic-email-verify-form {
11
+ display: flex;
12
+ flex-direction: column;
13
+ gap: var(--stuic-email-verify-form-gap);
14
+ }
15
+
16
+ .stuic-email-verify-form-heading {
17
+ margin: 0;
18
+ }
19
+
20
+ .stuic-email-verify-form-subheading {
21
+ margin: 0;
22
+ color: var(--stuic-email-verify-form-subheading-color);
23
+ font-size: var(--text-sm);
24
+ }
25
+
26
+ .stuic-email-verify-form-otp {
27
+ display: flex;
28
+ justify-content: center;
29
+ padding: 0.25rem 0;
30
+ }
31
+
32
+ .stuic-email-verify-form-attempts {
33
+ color: var(--stuic-email-verify-form-attempts-color);
34
+ font-size: var(--text-xs);
35
+ text-align: center;
36
+ }
37
+
38
+ .stuic-email-verify-form-submit {
39
+ margin-top: 0.25rem;
40
+ }
41
+
42
+ .stuic-email-verify-form-resend {
43
+ display: flex;
44
+ align-items: center;
45
+ justify-content: center;
46
+ gap: 0.25rem;
47
+ font-size: var(--text-sm);
48
+ color: var(--stuic-email-verify-form-resend-color);
49
+ }
50
+
51
+ .stuic-email-verify-form-resend-flash {
52
+ color: var(--stuic-email-verify-form-resend-flash-color);
53
+ }
54
+ }
@@ -0,0 +1 @@
1
+ export { default as EmailVerifyForm, type Props as EmailVerifyFormProps, } from "./EmailVerifyForm.svelte";
@@ -0,0 +1 @@
1
+ export { default as EmailVerifyForm, } from "./EmailVerifyForm.svelte";
@@ -6,9 +6,10 @@
6
6
  import type { LoginFormData } from "../LoginForm/_internal/login-form-types.js";
7
7
  import type { Props as RegisterFormProps } from "../RegisterForm/RegisterForm.svelte";
8
8
  import type { RegisterFormData } from "../RegisterForm/_internal/register-form-types.js";
9
+ import type { Props as EmailVerifyFormProps } from "../EmailVerifyForm/EmailVerifyForm.svelte";
9
10
  import type { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
10
11
 
11
- export type LoginOrRegisterFormMode = "login" | "register";
12
+ export type LoginOrRegisterFormMode = "login" | "register" | "verify";
12
13
 
13
14
  type InnerPropsCommonOmit =
14
15
  | "formData"
@@ -45,6 +46,27 @@
45
46
  /** Pass-through props for the inner RegisterForm (spread). */
46
47
  registerProps?: Omit<RegisterFormProps, InnerPropsCommonOmit>;
47
48
 
49
+ /**
50
+ * Bindable email used by EmailVerifyForm (typically copied from registerData.email
51
+ * when transitioning to "verify" mode).
52
+ */
53
+ verifyEmail?: string;
54
+
55
+ /** Called when the user submits a code in the verify view. */
56
+ onVerify?: (code: string) => void;
57
+
58
+ /** Called when the user clicks "Resend code" in the verify view. */
59
+ onResendCode?: () => Promise<void> | void;
60
+
61
+ /** Pass-through props for the inner EmailVerifyForm (spread). */
62
+ verifyProps?: Omit<
63
+ EmailVerifyFormProps,
64
+ "email" | "onSubmit" | "onResend" | "isSubmitting" | "t" | "notifications" | "footer"
65
+ >;
66
+
67
+ /** Reserved for future use (verify mode is not exposed in the default switcher). */
68
+ verifyModeLabel?: string;
69
+
48
70
  /** Override the built-in ButtonGroupRadio mode switcher. */
49
71
  modeSwitcher?: Snippet<
50
72
  [
@@ -102,17 +124,23 @@
102
124
  import { createEmptyLoginFormData } from "../LoginForm/_internal/login-form-utils.js";
103
125
  import RegisterForm from "../RegisterForm/RegisterForm.svelte";
104
126
  import { createEmptyRegisterFormData } from "../RegisterForm/_internal/register-form-utils.js";
127
+ import EmailVerifyForm from "../EmailVerifyForm/EmailVerifyForm.svelte";
105
128
  import ButtonGroupRadio from "../ButtonGroupRadio/ButtonGroupRadio.svelte";
106
129
 
107
130
  let {
108
131
  mode = $bindable("login"),
109
132
  loginData = $bindable(createEmptyLoginFormData()),
110
133
  registerData = $bindable(createEmptyRegisterFormData()),
134
+ verifyEmail = $bindable(""),
111
135
  onLogin,
112
136
  onRegister,
137
+ onVerify,
138
+ onResendCode,
113
139
  isSubmitting = false,
114
140
  loginProps,
115
141
  registerProps,
142
+ verifyProps,
143
+ verifyModeLabel: _verifyModeLabel,
116
144
  modeSwitcher,
117
145
  loginModeLabel,
118
146
  registerModeLabel,
@@ -138,10 +166,19 @@
138
166
  // effect (which would be prone to loops).
139
167
  function setMode(next: LoginOrRegisterFormMode) {
140
168
  if (next === mode) return;
169
+ const sourceEmail =
170
+ mode === "verify"
171
+ ? verifyEmail
172
+ : mode === "register"
173
+ ? registerData.email
174
+ : loginData.email;
141
175
  if (next === "register") {
142
- registerData.email = loginData.email;
176
+ registerData.email = sourceEmail;
177
+ } else if (next === "login") {
178
+ loginData.email = sourceEmail;
143
179
  } else {
144
- loginData.email = registerData.email;
180
+ // next === "verify"
181
+ verifyEmail = sourceEmail;
145
182
  }
146
183
  mode = next;
147
184
  }
@@ -157,21 +194,23 @@
157
194
  </script>
158
195
 
159
196
  <div class={_class} {...rest}>
160
- <!-- Mode switcher -->
161
- <div class={unstyled ? undefined : "stuic-login-or-register-form-switcher"}>
162
- {#if modeSwitcher}
163
- {@render modeSwitcher({ mode, setMode, t })}
164
- {:else}
165
- <ButtonGroupRadio
166
- options={switcherOptions}
167
- value={mode}
168
- onButtonClick={(idx) => {
169
- setMode(idx === 0 ? "login" : "register");
170
- return false;
171
- }}
172
- />
173
- {/if}
174
- </div>
197
+ <!-- Mode switcher (verify mode is never rendered as a tab — it's an outcome state) -->
198
+ {#if mode !== "verify"}
199
+ <div class={unstyled ? undefined : "stuic-login-or-register-form-switcher"}>
200
+ {#if modeSwitcher}
201
+ {@render modeSwitcher({ mode, setMode, t })}
202
+ {:else}
203
+ <ButtonGroupRadio
204
+ options={switcherOptions}
205
+ value={mode}
206
+ onButtonClick={(idx) => {
207
+ setMode(idx === 0 ? "login" : "register");
208
+ return false;
209
+ }}
210
+ />
211
+ {/if}
212
+ </div>
213
+ {/if}
175
214
 
176
215
  <!-- Active form -->
177
216
  <div class={unstyled ? undefined : "stuic-login-or-register-form-body"}>
@@ -185,7 +224,7 @@
185
224
  t={tProp}
186
225
  {...loginProps}
187
226
  />
188
- {:else}
227
+ {:else if mode === "register"}
189
228
  <!-- svelte-ignore binding_property_non_reactive -->
190
229
  <RegisterForm
191
230
  bind:formData={registerData}
@@ -195,11 +234,21 @@
195
234
  t={tProp}
196
235
  {...registerProps}
197
236
  />
237
+ {:else}
238
+ <EmailVerifyForm
239
+ email={verifyEmail || registerData.email || loginData.email}
240
+ onSubmit={(code) => onVerify?.(code)}
241
+ onResend={onResendCode}
242
+ {isSubmitting}
243
+ {notifications}
244
+ t={tProp}
245
+ {...verifyProps}
246
+ />
198
247
  {/if}
199
248
  </div>
200
249
 
201
- <!-- Shared social logins -->
202
- {#if socialLogins}
250
+ <!-- Shared social logins (hidden in verify mode — OAuth doesn't apply mid-verification) -->
251
+ {#if socialLogins && mode !== "verify"}
203
252
  <div class={unstyled ? undefined : "stuic-login-or-register-form-social"}>
204
253
  {#if socialDividerLabel !== false}
205
254
  <div class={unstyled ? undefined : "stuic-login-or-register-form-social-divider"}>
@@ -5,8 +5,9 @@ import type { Props as LoginFormProps } from "../LoginForm/LoginForm.svelte";
5
5
  import type { LoginFormData } from "../LoginForm/_internal/login-form-types.js";
6
6
  import type { Props as RegisterFormProps } from "../RegisterForm/RegisterForm.svelte";
7
7
  import type { RegisterFormData } from "../RegisterForm/_internal/register-form-types.js";
8
+ import type { Props as EmailVerifyFormProps } from "../EmailVerifyForm/EmailVerifyForm.svelte";
8
9
  import type { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
9
- export type LoginOrRegisterFormMode = "login" | "register";
10
+ export type LoginOrRegisterFormMode = "login" | "register" | "verify";
10
11
  type InnerPropsCommonOmit = "formData" | "onSubmit" | "isSubmitting" | "t" | "notifications" | "socialLogins" | "socialDividerLabel" | "footer";
11
12
  export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
12
13
  /** Bindable active mode. Default: "login" */
@@ -25,6 +26,19 @@ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children">
25
26
  loginProps?: Omit<LoginFormProps, InnerPropsCommonOmit>;
26
27
  /** Pass-through props for the inner RegisterForm (spread). */
27
28
  registerProps?: Omit<RegisterFormProps, InnerPropsCommonOmit>;
29
+ /**
30
+ * Bindable email used by EmailVerifyForm (typically copied from registerData.email
31
+ * when transitioning to "verify" mode).
32
+ */
33
+ verifyEmail?: string;
34
+ /** Called when the user submits a code in the verify view. */
35
+ onVerify?: (code: string) => void;
36
+ /** Called when the user clicks "Resend code" in the verify view. */
37
+ onResendCode?: () => Promise<void> | void;
38
+ /** Pass-through props for the inner EmailVerifyForm (spread). */
39
+ verifyProps?: Omit<EmailVerifyFormProps, "email" | "onSubmit" | "onResend" | "isSubmitting" | "t" | "notifications" | "footer">;
40
+ /** Reserved for future use (verify mode is not exposed in the default switcher). */
41
+ verifyModeLabel?: string;
28
42
  /** Override the built-in ButtonGroupRadio mode switcher. */
29
43
  modeSwitcher?: Snippet<[
30
44
  {
@@ -63,6 +77,6 @@ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children">
63
77
  unstyled?: boolean;
64
78
  class?: string;
65
79
  }
66
- declare const LoginOrRegisterForm: import("svelte").Component<Props, {}, "mode" | "loginData" | "registerData">;
80
+ declare const LoginOrRegisterForm: import("svelte").Component<Props, {}, "mode" | "loginData" | "registerData" | "verifyEmail">;
67
81
  type LoginOrRegisterForm = ReturnType<typeof LoginOrRegisterForm>;
68
82
  export default LoginOrRegisterForm;
@@ -19,18 +19,29 @@
19
19
  /** Bindable register formData */
20
20
  registerData?: RegisterFormData;
21
21
 
22
+ /** Bindable email used by EmailVerifyForm. */
23
+ verifyEmail?: string;
24
+
22
25
  onLogin: (data: LoginFormData) => void;
23
26
  onRegister: (data: RegisterFormData) => void;
24
27
 
28
+ /** Called when the user submits a code in the verify view. */
29
+ onVerify?: (code: string) => void;
30
+
31
+ /** Called when the user clicks "Resend code" in the verify view. */
32
+ onResendCode?: () => Promise<void> | void;
33
+
25
34
  isSubmitting?: boolean;
26
35
 
27
36
  loginProps?: InnerProps["loginProps"];
28
37
  registerProps?: InnerProps["registerProps"];
38
+ verifyProps?: InnerProps["verifyProps"];
29
39
 
30
40
  modeSwitcher?: InnerProps["modeSwitcher"];
31
41
 
32
42
  loginModeLabel?: string;
33
43
  registerModeLabel?: string;
44
+ verifyModeLabel?: string;
34
45
 
35
46
  /** Shared social logins (rendered below the active form). */
36
47
  socialLogins?: Snippet;
@@ -84,14 +95,19 @@
84
95
  mode = $bindable("login"),
85
96
  loginData = $bindable(createEmptyLoginFormData()),
86
97
  registerData = $bindable(createEmptyRegisterFormData()),
98
+ verifyEmail = $bindable(""),
87
99
  onLogin,
88
100
  onRegister,
101
+ onVerify,
102
+ onResendCode,
89
103
  isSubmitting = false,
90
104
  loginProps,
91
105
  registerProps,
106
+ verifyProps,
92
107
  modeSwitcher,
93
108
  loginModeLabel,
94
109
  registerModeLabel,
110
+ verifyModeLabel,
95
111
  socialLogins,
96
112
  socialDividerLabel,
97
113
  footer,
@@ -112,9 +128,11 @@
112
128
 
113
129
  let resolvedTitle = $derived(
114
130
  title ??
115
- (mode === "login"
116
- ? t("login_or_register_form.modal_title_login")
117
- : t("login_or_register_form.modal_title_register"))
131
+ (mode === "verify"
132
+ ? t("login_or_register_form.modal_title_verify")
133
+ : mode === "login"
134
+ ? t("login_or_register_form.modal_title_login")
135
+ : t("login_or_register_form.modal_title_register"))
118
136
  );
119
137
 
120
138
  let modal: Modal = $state()!;
@@ -164,14 +182,19 @@
164
182
  bind:mode
165
183
  bind:loginData
166
184
  bind:registerData
185
+ bind:verifyEmail
167
186
  {onLogin}
168
187
  {onRegister}
188
+ {onVerify}
189
+ {onResendCode}
169
190
  {isSubmitting}
170
191
  {loginProps}
171
192
  {registerProps}
193
+ {verifyProps}
172
194
  {modeSwitcher}
173
195
  {loginModeLabel}
174
196
  {registerModeLabel}
197
+ {verifyModeLabel}
175
198
  {socialLogins}
176
199
  {socialDividerLabel}
177
200
  {footer}
@@ -11,14 +11,22 @@ export interface Props {
11
11
  loginData?: LoginFormData;
12
12
  /** Bindable register formData */
13
13
  registerData?: RegisterFormData;
14
+ /** Bindable email used by EmailVerifyForm. */
15
+ verifyEmail?: string;
14
16
  onLogin: (data: LoginFormData) => void;
15
17
  onRegister: (data: RegisterFormData) => void;
18
+ /** Called when the user submits a code in the verify view. */
19
+ onVerify?: (code: string) => void;
20
+ /** Called when the user clicks "Resend code" in the verify view. */
21
+ onResendCode?: () => Promise<void> | void;
16
22
  isSubmitting?: boolean;
17
23
  loginProps?: InnerProps["loginProps"];
18
24
  registerProps?: InnerProps["registerProps"];
25
+ verifyProps?: InnerProps["verifyProps"];
19
26
  modeSwitcher?: InnerProps["modeSwitcher"];
20
27
  loginModeLabel?: string;
21
28
  registerModeLabel?: string;
29
+ verifyModeLabel?: string;
22
30
  /** Shared social logins (rendered below the active form). */
23
31
  socialLogins?: Snippet;
24
32
  socialDividerLabel?: string | false;
@@ -50,6 +58,6 @@ export interface Props {
50
58
  declare const LoginOrRegisterFormModal: import("svelte").Component<Props, {
51
59
  open: (openerOrEvent?: null | HTMLElement | MouseEvent) => void;
52
60
  close: () => void;
53
- }, "visible" | "mode" | "loginData" | "registerData">;
61
+ }, "visible" | "mode" | "loginData" | "registerData" | "verifyEmail">;
54
62
  type LoginOrRegisterFormModal = ReturnType<typeof LoginOrRegisterFormModal>;
55
63
  export default LoginOrRegisterFormModal;
@@ -3,9 +3,11 @@ import { replaceMap } from "../../../utils/replace-map.js";
3
3
  const DEFAULTS = {
4
4
  "login_or_register_form.mode_login": "Log in",
5
5
  "login_or_register_form.mode_register": "Sign up",
6
+ "login_or_register_form.mode_verify": "Verify",
6
7
  "login_or_register_form.social_divider": "or continue with",
7
8
  "login_or_register_form.modal_title_login": "Log In",
8
9
  "login_or_register_form.modal_title_register": "Create account",
10
+ "login_or_register_form.modal_title_verify": "Verify your email",
9
11
  };
10
12
  export function t_default(k, values = null, fallback = "") {
11
13
  const out = DEFAULTS[k] ?? (typeof fallback === "string" ? fallback : "") ?? k;
@@ -0,0 +1,30 @@
1
+ import type { HTMLAttributes, HTMLInputAttributes } from "svelte/elements";
2
+ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "oninput"> {
3
+ /** Bindable concatenated value (e.g., "123456"). Length always equals `length` or shorter. */
4
+ value?: string;
5
+ /** Number of slots. Default: 6. Sensible range: 4..8. */
6
+ length?: number;
7
+ /** Fired when all slots are filled. Receives the concatenated string. */
8
+ onComplete?: (code: string) => void;
9
+ /** Fired on every change (keystroke, paste, backspace). */
10
+ oninput?: (value: string) => void;
11
+ /** When true, slots render in error styling (red border + aria-invalid). */
12
+ error?: boolean;
13
+ /** Disables all slots. */
14
+ disabled?: boolean;
15
+ /** Auto-focus the first empty slot on mount. Default: true. */
16
+ autoFocus?: boolean;
17
+ /** Restrict input. "numeric" (default) | "alphanumeric". */
18
+ mode?: "numeric" | "alphanumeric";
19
+ /** Pass-through to first slot for browser auto-fill. Default: "one-time-code". */
20
+ autocomplete?: HTMLInputAttributes["autocomplete"];
21
+ /** Skip default styling */
22
+ unstyled?: boolean;
23
+ /** Additional CSS classes on the root */
24
+ class?: string;
25
+ /** Bindable root element ref */
26
+ el?: HTMLDivElement;
27
+ }
28
+ declare const OtpInput: import("svelte").Component<Props, {}, "el" | "value">;
29
+ type OtpInput = ReturnType<typeof OtpInput>;
30
+ export default OtpInput;
@@ -0,0 +1,55 @@
1
+ # OtpInput
2
+
3
+ Generic N-slot one-time-code input. 6 digits by default, configurable. Building block for email verification, 2FA, and password-reset OTP flows.
4
+
5
+ ## Usage
6
+
7
+ ```svelte
8
+ <script lang="ts">
9
+ import { OtpInput } from "@marianmeres/stuic";
10
+
11
+ let code = $state("");
12
+ </script>
13
+
14
+ <OtpInput
15
+ bind:value={code}
16
+ length={6}
17
+ onComplete={(c) => console.log("Got code:", c)}
18
+ />
19
+ ```
20
+
21
+ ## Props
22
+
23
+ | Prop | Type | Default | Description |
24
+ | -------------- | ------------------------------- | ------------------ | ----------------------------------------------------------------- |
25
+ | `value` | `string` | `""` | Bindable concatenated value |
26
+ | `length` | `number` | `6` | Number of slots |
27
+ | `onComplete` | `(code: string) => void` | — | Fired when all slots are filled |
28
+ | `oninput` | `(value: string) => void` | — | Fired on every change |
29
+ | `error` | `boolean` | `false` | Renders error styling + `aria-invalid` |
30
+ | `disabled` | `boolean` | `false` | Disables all slots |
31
+ | `autoFocus` | `boolean` | `true` | Auto-focus the first empty slot on mount |
32
+ | `mode` | `"numeric" \| "alphanumeric"` | `"numeric"` | Restrict input characters |
33
+ | `autocomplete` | `string` | `"one-time-code"` | Pass-through to first slot for browser/iOS auto-fill |
34
+ | `unstyled` | `boolean` | `false` | Skip default styling |
35
+ | `class` | `string` | — | Additional CSS classes on the root |
36
+ | `el` | `HTMLDivElement` | — | Bindable root element ref |
37
+
38
+ ## Behavior
39
+
40
+ - **Auto-advance**: typing in slot K moves focus to slot K+1.
41
+ - **Backspace**: clears current slot; if already empty, jumps to and clears slot K-1.
42
+ - **Arrow keys**: ←/→ navigate without modifying values.
43
+ - **Paste**: any slot accepts paste; the pasted string is sanitized to allowed chars, truncated to `length`, and distributed across all slots starting from slot 0.
44
+ - **iOS/Android SMS auto-fill**: first slot has `autocomplete="one-time-code"` by default. The OS auto-fills the full code into slot 0 — the input handler distributes it.
45
+ - **Click**: focusing a filled slot selects its content (for easy overwrite).
46
+ - **Enter**: bubbles a native submit to the surrounding `<form>`.
47
+ - **Accessibility**: each slot gets `aria-label="Digit {n+1} of {length}"`.
48
+
49
+ ## CSS Tokens
50
+
51
+ Prefix: `--stuic-otp-input-*`
52
+
53
+ `gap`, `slot-size`, `font-size`, `bg`, `color`, `radius`, `border-width`, `border-color`, `border-color-focus`, `border-color-error`, `transition`
54
+
55
+ The shared structural tokens `--stuic-radius`, `--stuic-border-width`, and `--stuic-transition` are used as fallbacks.
@@ -0,0 +1,41 @@
1
+ @layer components {
2
+ .stuic-otp-input {
3
+ display: flex;
4
+ gap: var(--stuic-otp-input-gap, 0.5rem);
5
+ }
6
+
7
+ .stuic-otp-input-slot {
8
+ width: var(--stuic-otp-input-slot-size, 2.75rem);
9
+ height: var(--stuic-otp-input-slot-size, 2.75rem);
10
+ text-align: center;
11
+ font-size: var(--stuic-otp-input-font-size, 1.25rem);
12
+ font-variant-numeric: tabular-nums;
13
+ background: var(--stuic-otp-input-bg, var(--stuic-color-background));
14
+ color: var(--stuic-otp-input-color, var(--stuic-color-foreground));
15
+ border-radius: var(--stuic-otp-input-radius, var(--stuic-radius));
16
+ border-style: solid;
17
+ border-width: var(--stuic-otp-input-border-width, var(--stuic-border-width));
18
+ border-color: var(--stuic-otp-input-border-color, var(--stuic-color-border));
19
+ transition: border-color var(--stuic-otp-input-transition, var(--stuic-transition));
20
+ }
21
+
22
+ .stuic-otp-input-slot:focus {
23
+ border-color: var(
24
+ --stuic-otp-input-border-color-focus,
25
+ var(--stuic-color-primary)
26
+ );
27
+ outline: none;
28
+ }
29
+
30
+ .stuic-otp-input-slot:disabled {
31
+ opacity: 0.5;
32
+ cursor: not-allowed;
33
+ }
34
+
35
+ .stuic-otp-input[data-error="true"] .stuic-otp-input-slot {
36
+ border-color: var(
37
+ --stuic-otp-input-border-color-error,
38
+ var(--stuic-color-destructive)
39
+ );
40
+ }
41
+ }
@@ -0,0 +1 @@
1
+ export { default as OtpInput, type Props as OtpInputProps, } from "./OtpInput.svelte";
@@ -0,0 +1 @@
1
+ export { default as OtpInput, } from "./OtpInput.svelte";
package/dist/index.css CHANGED
@@ -71,6 +71,7 @@ In practice:
71
71
  @import "./components/DataTable/index.css";
72
72
  @import "./components/DismissibleMessage/index.css";
73
73
  @import "./components/DropdownMenu/index.css";
74
+ @import "./components/EmailVerifyForm/index.css";
74
75
  @import "./components/H/index.css";
75
76
  @import "./components/Header/index.css";
76
77
  @import "./components/ImageCycler/index.css";
@@ -82,6 +83,7 @@ In practice:
82
83
  @import "./components/ModalDialog/index.css";
83
84
  @import "./components/Nav/index.css";
84
85
  @import "./components/Notifications/index.css";
86
+ @import "./components/OtpInput/index.css";
85
87
  @import "./components/PricingTable/index.css";
86
88
  @import "./components/Progress/index.css";
87
89
  @import "./components/Separator/index.css";
package/dist/index.d.ts CHANGED
@@ -43,6 +43,7 @@ export * from "./components/DataTable/index.js";
43
43
  export * from "./components/DismissibleMessage/index.js";
44
44
  export * from "./components/Drawer/index.js";
45
45
  export * from "./components/DropdownMenu/index.js";
46
+ export * from "./components/EmailVerifyForm/index.js";
46
47
  export * from "./components/H/index.js";
47
48
  export * from "./components/Header/index.js";
48
49
  export * from "./components/ImageCycler/index.js";
@@ -58,6 +59,7 @@ export * from "./components/Modal/index.js";
58
59
  export * from "./components/ModalDialog/index.js";
59
60
  export * from "./components/Nav/index.js";
60
61
  export * from "./components/Notifications/index.js";
62
+ export * from "./components/OtpInput/index.js";
61
63
  export * from "./components/PricingTable/index.js";
62
64
  export * from "./components/Progress/index.js";
63
65
  export * from "./components/Separator/index.js";
package/dist/index.js CHANGED
@@ -44,6 +44,7 @@ export * from "./components/DataTable/index.js";
44
44
  export * from "./components/DismissibleMessage/index.js";
45
45
  export * from "./components/Drawer/index.js";
46
46
  export * from "./components/DropdownMenu/index.js";
47
+ export * from "./components/EmailVerifyForm/index.js";
47
48
  export * from "./components/H/index.js";
48
49
  export * from "./components/Header/index.js";
49
50
  export * from "./components/ImageCycler/index.js";
@@ -59,6 +60,7 @@ export * from "./components/Modal/index.js";
59
60
  export * from "./components/ModalDialog/index.js";
60
61
  export * from "./components/Nav/index.js";
61
62
  export * from "./components/Notifications/index.js";
63
+ export * from "./components/OtpInput/index.js";
62
64
  export * from "./components/PricingTable/index.js";
63
65
  export * from "./components/Progress/index.js";
64
66
  export * from "./components/Separator/index.js";
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- 50 Svelte 5 component directories with consistent API patterns. All use runes-based reactivity.
5
+ 55 Svelte 5 component directories with consistent API patterns. All use runes-based reactivity.
6
6
 
7
7
  ## Component Categories
8
8
 
@@ -62,6 +62,10 @@
62
62
  | FieldKeyValues | Key-value pair editor |
63
63
  | FieldAssets | File/asset management |
64
64
  | LoginForm, LoginFormModal | Standalone login form with optional modal variant |
65
+ | RegisterForm | Standalone registration form with declarative extra fields |
66
+ | LoginOrRegisterForm, LoginOrRegisterFormModal | Composite login/register/verify form (3 modes, shared social-logins) |
67
+ | EmailVerifyForm | Post-registration email-verify form (OtpInput + resend cooldown) |
68
+ | OtpInput | Generic N-slot one-time-code input (numeric/alphanumeric, paste-distribute) |
65
69
 
66
70
  ### Display
67
71
 
@@ -154,6 +158,201 @@ Prefix: `--stuic-login-form-*`
154
158
 
155
159
  ---
156
160
 
161
+ ## RegisterForm
162
+
163
+ Standalone registration form. Mirrors `LoginForm` conventions: `formData`, `onSubmit`, validation, errors, i18n, notifications, social-logins. Adds declarative `extraFields` (top/bottom positioning, custom validators) and an `extraFieldsSlot` escape hatch (e.g., terms checkbox).
164
+
165
+ ### Exports
166
+
167
+ | Export | Kind | Description |
168
+ | ----------------------------- | --------- | -------------------------------------- |
169
+ | `RegisterForm` | component | Main register form |
170
+ | `RegisterFormProps` | type | Props for RegisterForm |
171
+ | `RegisterFormData` | type | `{ email, password, passwordConfirm, extra }` |
172
+ | `RegisterFormValidationError` | type | `{ field, message }` |
173
+ | `RegisterFieldConfig` | type | Declarative extra-field descriptor |
174
+ | `createEmptyRegisterFormData` | function | Factory for an empty `RegisterFormData` |
175
+
176
+ ### Key Props
177
+
178
+ | Prop | Type | Default | Description |
179
+ | --------------------- | --------------------------------- | -------- | ------------------------------------------ |
180
+ | `formData` | `RegisterFormData` | empty | Bindable form data |
181
+ | `onSubmit` | `(data) => void` | required | Submit callback |
182
+ | `isSubmitting` | `boolean` | `false` | Disables CTA |
183
+ | `errors` | `RegisterFormValidationError[]` | `[]` | Field-specific server errors |
184
+ | `error` | `string` | — | General error (alert above form) |
185
+ | `showPasswordConfirm` | `boolean` | `true` | Render password-confirm field |
186
+ | `passwordMinLength` | `number` | `8` | Min password length (input + validator) |
187
+ | `extraFields` | `RegisterFieldConfig[]` | `[]` | Declarative extra fields (top/bottom) |
188
+ | `extraFieldsSlot` | `Snippet` | — | Escape-hatch for non-FieldInput extras |
189
+ | `submitButton` | `Snippet` | — | Custom CTA section |
190
+ | `socialLogins` | `Snippet` | — | OAuth buttons below form |
191
+ | `footer` | `Snippet` | — | Content below form |
192
+ | `notifications` | `NotificationsStack` | — | Route errors to notifications |
193
+ | `compact` | `boolean` | `false` | Compact layout |
194
+ | `t` | `TranslateFn` | built-in | Translation function |
195
+
196
+ ### CSS Tokens
197
+
198
+ Prefix: `--stuic-register-form-*`
199
+
200
+ `gap`, `gap-row`, `social-margin-top`, `social-gap`, `social-divider-color`, `social-divider-line-color`, `social-divider-font-size`, `social-divider-margin-bottom`
201
+
202
+ ---
203
+
204
+ ## LoginOrRegisterForm
205
+
206
+ Composite form that toggles between `LoginForm`, `RegisterForm`, and (since 3.71) `EmailVerifyForm`. The mode switcher only exposes login/register tabs; verify is an outcome state entered programmatically (typically after a `requiresVerification` register response).
207
+
208
+ ### Exports
209
+
210
+ | Export | Kind | Description |
211
+ | ------------------------------ | --------- | -------------------------------------- |
212
+ | `LoginOrRegisterForm` | component | Composite form |
213
+ | `LoginOrRegisterFormModal` | component | Modal-wrapped variant |
214
+ | `LoginOrRegisterFormProps` | type | Props for the composite form |
215
+ | `LoginOrRegisterFormModalProps`| type | Props for the modal |
216
+ | `LoginOrRegisterFormMode` | type | `"login" \| "register" \| "verify"` |
217
+
218
+ ### Key Props
219
+
220
+ | Prop | Type | Default | Description |
221
+ | ------------------- | ---------------------------------------------------- | ----------- | ---------------------------------------------------------------------------- |
222
+ | `mode` | `LoginOrRegisterFormMode` | `"login"` | Bindable active mode |
223
+ | `loginData` | `LoginFormData` | empty | Bindable login form data |
224
+ | `registerData` | `RegisterFormData` | empty | Bindable register form data |
225
+ | `verifyEmail` | `string` | `""` | Bindable email used by EmailVerifyForm (auto-seeded on transitions) |
226
+ | `onLogin` | `(data) => void` | required | Login submit callback |
227
+ | `onRegister` | `(data) => void` | required | Register submit callback |
228
+ | `onVerify` | `(code: string) => void` | — | Verify submit callback (required only when using verify mode) |
229
+ | `onResendCode` | `() => Promise<void> \| void` | — | Resend handler — when set, EmailVerifyForm renders the resend control |
230
+ | `loginProps` | `Partial<LoginFormProps>` | — | Pass-through to inner LoginForm |
231
+ | `registerProps` | `Partial<RegisterFormProps>` | — | Pass-through to inner RegisterForm |
232
+ | `verifyProps` | `Partial<EmailVerifyFormProps>` | — | Pass-through to inner EmailVerifyForm (e.g., `error`, `attemptsRemaining`) |
233
+ | `modeSwitcher` | `Snippet` | — | Override the built-in ButtonGroupRadio |
234
+ | `socialLogins` | `Snippet` | — | Shared OAuth buttons (hidden in verify mode) |
235
+ | `footer` | `Snippet<[{ mode, setMode }]>` | — | Mode-aware footer |
236
+ | `isSubmitting` | `boolean` | `false` | Forwarded to all three forms |
237
+ | `notifications` | `NotificationsStack` | — | Routes errors to notifications |
238
+ | `t` | `TranslateFn` | built-in | Translation function |
239
+
240
+ ### Modal additions (`LoginOrRegisterFormModal`)
241
+
242
+ Inherits all `LoginOrRegisterForm` props, plus:
243
+
244
+ | Prop | Type | Default | Description |
245
+ | ------------ | --------------------- | --------- | ----------------------------------------------------------------- |
246
+ | `title` | `string` | mode-aware | Modal title (defaults to "Log In" / "Create account" / "Verify your email") |
247
+ | `visible` | `boolean` | `false` | Bindable visibility |
248
+ | `trigger` | `Snippet<[{ open }]>` | — | Optional trigger element |
249
+ | `noXClose` | `boolean` | `false` | Hide close button |
250
+
251
+ ### CSS Tokens
252
+
253
+ Prefix: `--stuic-login-or-register-form-*`
254
+
255
+ `gap`, `switcher-margin-bottom`, `social-margin-top`, `social-gap`, `social-divider-*`
256
+
257
+ ### i18n
258
+
259
+ | Key | Default |
260
+ | ------------------------------------------------ | -------------------- |
261
+ | `login_or_register_form.mode_login` | `Log in` |
262
+ | `login_or_register_form.mode_register` | `Sign up` |
263
+ | `login_or_register_form.mode_verify` | `Verify` |
264
+ | `login_or_register_form.modal_title_login` | `Log In` |
265
+ | `login_or_register_form.modal_title_register` | `Create account` |
266
+ | `login_or_register_form.modal_title_verify` | `Verify your email` |
267
+ | `login_or_register_form.social_divider` | `or continue with` |
268
+
269
+ ---
270
+
271
+ ## EmailVerifyForm
272
+
273
+ Post-registration email-verify form. Drop-in peer to `LoginForm` / `RegisterForm`. Renders a heading, a subhead with the bolded email address, a general error banner, an `OtpInput` (default 6 digits, auto-submits on completion), an optional attempts hint, a primary submit button, and an optional resend control with built-in cooldown countdown.
274
+
275
+ ### Exports
276
+
277
+ | Export | Kind | Description |
278
+ | ----------------------- | --------- | -------------------------- |
279
+ | `EmailVerifyForm` | component | Email-verify form |
280
+ | `EmailVerifyFormProps` | type | Props for EmailVerifyForm |
281
+
282
+ ### Key Props
283
+
284
+ | Prop | Type | Default | Description |
285
+ | ----------------------- | ------------------------------------------ | -------- | -------------------------------------------------------- |
286
+ | `email` | `string` | required | Email shown in the subhead |
287
+ | `onSubmit` | `(code: string) => void` | required | Called with the entered code (auto + manual submit) |
288
+ | `onResend` | `() => Promise<void> \| void` | — | When set, renders the resend control |
289
+ | `resendCooldownSeconds` | `number` | `30` | Cooldown after a successful resend |
290
+ | `isSubmitting` | `boolean` | `false` | Disables submit + OtpInput |
291
+ | `error` | `string` | — | General error (alert + applies error styling to OtpInput)|
292
+ | `attemptsRemaining` | `number` | — | Inline "{count} attempts remaining" hint |
293
+ | `codeLength` | `number` | `6` | Forwarded to OtpInput |
294
+ | `otpInputProps` | `Partial<OtpInputProps>` | — | Pass-through to inner OtpInput |
295
+ | `notifications` | `NotificationsStack` | — | Route errors to notifications |
296
+ | `submitButton` | `Snippet` | — | Override submit section |
297
+ | `footer` | `Snippet` | — | Content below resend control |
298
+ | `t` | `TranslateFn` | built-in | Translation function |
299
+
300
+ ### CSS Tokens
301
+
302
+ Prefix: `--stuic-email-verify-form-*`
303
+
304
+ `gap`, `subheading-color`, `attempts-color`, `resend-color`, `resend-flash-color`
305
+
306
+ ### i18n keys
307
+
308
+ `email_verify_form.heading`, `subheading`, `submit`, `submitting`, `resend_prompt`, `resend`, `resend_cooldown`, `resent`, `attempts_remaining`
309
+
310
+ ---
311
+
312
+ ## OtpInput
313
+
314
+ Generic N-slot one-time-code input. 6 numeric digits by default, configurable to 4–8. Building block for email-verify, future 2FA, and password-reset OTP flows.
315
+
316
+ ### Exports
317
+
318
+ | Export | Kind | Description |
319
+ | --------------- | --------- | --------------------- |
320
+ | `OtpInput` | component | OTP input |
321
+ | `OtpInputProps` | type | Props for OtpInput |
322
+
323
+ ### Key Props
324
+
325
+ | Prop | Type | Default | Description |
326
+ | -------------- | ------------------------------- | ------------------ | ----------------------------------------------------------------- |
327
+ | `value` | `string` | `""` | Bindable concatenated value |
328
+ | `length` | `number` | `6` | Number of slots |
329
+ | `onComplete` | `(code: string) => void` | — | Fired when all slots are filled |
330
+ | `oninput` | `(value: string) => void` | — | Fired on every change |
331
+ | `error` | `boolean` | `false` | Renders error styling + `aria-invalid` |
332
+ | `disabled` | `boolean` | `false` | Disables all slots |
333
+ | `autoFocus` | `boolean` | `true` | Auto-focus first empty slot on mount |
334
+ | `mode` | `"numeric" \| "alphanumeric"` | `"numeric"` | Restrict input characters |
335
+ | `autocomplete` | `string` | `"one-time-code"` | Pass-through to first slot for browser/iOS auto-fill |
336
+
337
+ ### Behavior
338
+
339
+ - Auto-advance on input; backspace clears or jumps back
340
+ - Arrow keys navigate without modifying values
341
+ - Paste anywhere distributes from slot 0 (after sanitization + truncation)
342
+ - iOS / Android SMS auto-fill works via `autocomplete="one-time-code"` on slot 0
343
+ - Each slot announces `aria-label="Digit {n+1} of {length}"`
344
+ - Enter bubbles native submit to surrounding `<form>`
345
+
346
+ ### CSS Tokens
347
+
348
+ Prefix: `--stuic-otp-input-*`
349
+
350
+ `gap`, `slot-size`, `font-size`, `bg`, `color`, `radius`, `border-width`, `border-color`, `border-color-focus`, `border-color-error`, `transition`
351
+
352
+ Falls back to shared structural tokens `--stuic-radius`, `--stuic-border-width`, `--stuic-transition`.
353
+
354
+ ---
355
+
157
356
  ## FieldObject
158
357
 
159
358
  Dual-mode JSON object editor with pretty-print and raw edit modes.
@@ -578,6 +777,10 @@ Prefix: `--stuic-tree-*`
578
777
  | src/lib/components/Modal/ | Complex component example |
579
778
  | src/lib/components/Input/ | Form field patterns (incl. FieldObject) |
580
779
  | src/lib/components/LoginForm/ | Standalone login form + modal variant |
780
+ | src/lib/components/RegisterForm/ | Standalone registration form |
781
+ | src/lib/components/LoginOrRegisterForm/ | Composite login/register/verify form + modal |
782
+ | src/lib/components/EmailVerifyForm/ | Post-registration email-verify form |
783
+ | src/lib/components/OtpInput/ | N-slot one-time-code input |
581
784
  | src/lib/components/Checkout/ | E-commerce checkout flow (14 exported sub-components) |
582
785
  | src/lib/components/Card/ | Card with image/title/footer variants |
583
786
  | src/lib/components/Tree/ | Hierarchical tree with drag-and-drop |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.70.1",
3
+ "version": "3.71.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",