@exegia/corpora-ui 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-lib/components/blocks/auth/__tests__/linked-accounts-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/__tests__/onboarding-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/__tests__/passkey-manager-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/__tests__/passkey-sign-in-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/__tests__/signup-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/__tests__/update-password-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/linked-accounts-block.d.ts +30 -0
- package/dist-lib/components/blocks/auth/onboarding/profile-step.d.ts +19 -0
- package/dist-lib/components/blocks/auth/onboarding-block.d.ts +69 -0
- package/dist-lib/components/blocks/auth/passkey-manager-block.d.ts +38 -0
- package/dist-lib/components/blocks/auth/passkey-sign-in-block.d.ts +27 -0
- package/dist-lib/components/blocks/auth/signup-block.d.ts +16 -1
- package/dist-lib/components/blocks/auth/update-password-block.d.ts +28 -0
- package/dist-lib/components/composed/social-providers.d.ts +7 -0
- package/dist-lib/index.d.ts +5 -0
- package/dist-lib/index.js +1122 -392
- package/dist-lib/index.js.map +1 -1
- package/package.json +5 -1
- package/src/components/blocks/auth/__tests__/linked-accounts-block.test.tsx +110 -0
- package/src/components/blocks/auth/__tests__/onboarding-block.test.tsx +156 -0
- package/src/components/blocks/auth/__tests__/passkey-manager-block.test.tsx +131 -0
- package/src/components/blocks/auth/__tests__/passkey-sign-in-block.test.tsx +59 -0
- package/src/components/blocks/auth/__tests__/signup-block.test.tsx +176 -0
- package/src/components/blocks/auth/__tests__/update-password-block.test.tsx +95 -0
- package/src/components/blocks/auth/linked-accounts-block.tsx +223 -0
- package/src/components/blocks/auth/onboarding/profile-step.tsx +227 -0
- package/src/components/blocks/auth/onboarding-block.tsx +240 -0
- package/src/components/blocks/auth/passkey-manager-block.tsx +334 -0
- package/src/components/blocks/auth/passkey-sign-in-block.tsx +81 -0
- package/src/components/blocks/auth/signup-block.tsx +43 -10
- package/src/components/blocks/auth/update-password-block.tsx +157 -0
- package/src/components/composed/social-providers.tsx +3 -2
- package/src/index.ts +5 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import { UpdatePasswordBlock } from "../update-password-block";
|
|
6
|
+
|
|
7
|
+
const STRONG = "Str0ngPassword";
|
|
8
|
+
|
|
9
|
+
describe("UpdatePasswordBlock", () => {
|
|
10
|
+
test("gates the confirm field behind a strong new password", async () => {
|
|
11
|
+
const user = userEvent.setup();
|
|
12
|
+
render(<UpdatePasswordBlock />);
|
|
13
|
+
|
|
14
|
+
expect(screen.queryByLabelText("Confirm new password")).toBeNull();
|
|
15
|
+
|
|
16
|
+
await user.type(screen.getByLabelText("New password"), STRONG);
|
|
17
|
+
|
|
18
|
+
expect(
|
|
19
|
+
await screen.findByLabelText("Confirm new password"),
|
|
20
|
+
).toBeDefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("gates the submit button behind matching passwords", async () => {
|
|
24
|
+
const user = userEvent.setup();
|
|
25
|
+
render(<UpdatePasswordBlock />);
|
|
26
|
+
|
|
27
|
+
await user.type(screen.getByLabelText("New password"), STRONG);
|
|
28
|
+
await user.type(
|
|
29
|
+
await screen.findByLabelText("Confirm new password"),
|
|
30
|
+
"different",
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(screen.queryByRole("button", { name: "Update password" })).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("submits the new password and shows the success panel", async () => {
|
|
37
|
+
const user = userEvent.setup();
|
|
38
|
+
const onSubmit = mock(async () => {});
|
|
39
|
+
render(<UpdatePasswordBlock onSubmit={onSubmit} />);
|
|
40
|
+
|
|
41
|
+
await user.type(screen.getByLabelText("New password"), STRONG);
|
|
42
|
+
await user.type(
|
|
43
|
+
await screen.findByLabelText("Confirm new password"),
|
|
44
|
+
STRONG,
|
|
45
|
+
);
|
|
46
|
+
await user.click(
|
|
47
|
+
await screen.findByRole("button", { name: "Update password" }),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(onSubmit).toHaveBeenCalledWith({ password: STRONG });
|
|
51
|
+
expect(await screen.findByText("Password updated")).toBeDefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("surfaces a rejection from onSubmit and stays on the form", async () => {
|
|
55
|
+
const user = userEvent.setup();
|
|
56
|
+
render(
|
|
57
|
+
<UpdatePasswordBlock
|
|
58
|
+
onSubmit={async () => {
|
|
59
|
+
throw new Error("Password was used recently.");
|
|
60
|
+
}}
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
await user.type(screen.getByLabelText("New password"), STRONG);
|
|
65
|
+
await user.type(
|
|
66
|
+
await screen.findByLabelText("Confirm new password"),
|
|
67
|
+
STRONG,
|
|
68
|
+
);
|
|
69
|
+
await user.click(
|
|
70
|
+
await screen.findByRole("button", { name: "Update password" }),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const alert = await screen.findByRole("alert");
|
|
74
|
+
expect(alert.textContent).toBe("Password was used recently.");
|
|
75
|
+
expect(screen.queryByText("Password updated")).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("flags a diverged confirmation, but not a prefix mid-typing", async () => {
|
|
79
|
+
const user = userEvent.setup();
|
|
80
|
+
render(<UpdatePasswordBlock />);
|
|
81
|
+
|
|
82
|
+
await user.type(screen.getByLabelText("New password"), STRONG);
|
|
83
|
+
const confirm = await screen.findByLabelText("Confirm new password");
|
|
84
|
+
|
|
85
|
+
await user.type(confirm, STRONG.slice(0, 4));
|
|
86
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
87
|
+
|
|
88
|
+
await user.type(confirm, "X");
|
|
89
|
+
await waitFor(() => {
|
|
90
|
+
expect(screen.getByRole("alert").textContent).toBe(
|
|
91
|
+
"Passwords do not match.",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
SOCIAL_PROVIDERS,
|
|
8
|
+
type SocialProvider,
|
|
9
|
+
} from "@/components/composed/social-providers";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import {
|
|
12
|
+
Card,
|
|
13
|
+
CardDescription,
|
|
14
|
+
CardHeader,
|
|
15
|
+
CardPanel,
|
|
16
|
+
CardTitle,
|
|
17
|
+
} from "@/components/ui/card";
|
|
18
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
19
|
+
import { cn } from "@/lib/utils";
|
|
20
|
+
import { AuthError, EASE } from "./auth-shell";
|
|
21
|
+
|
|
22
|
+
/** One sign-in identity attached to the account. */
|
|
23
|
+
export interface LinkedIdentity {
|
|
24
|
+
id: string;
|
|
25
|
+
provider: SocialProvider;
|
|
26
|
+
/** Account address shown under the provider name, when known. */
|
|
27
|
+
email?: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LinkedAccountsBlockProps {
|
|
31
|
+
title?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
/** Identities already attached to the account. */
|
|
34
|
+
identities?: LinkedIdentity[];
|
|
35
|
+
/** Providers offered as connect candidates; connected ones are filtered out. */
|
|
36
|
+
providers?: SocialProvider[];
|
|
37
|
+
/** Shows the loading row instead of the list. */
|
|
38
|
+
loading?: boolean;
|
|
39
|
+
/** Reject (or throw) to show the inline error. */
|
|
40
|
+
onLink?: (provider: SocialProvider) => Promise<void> | void;
|
|
41
|
+
onUnlink?: (id: string) => Promise<void> | void;
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const LAST_METHOD_EXPLANATION =
|
|
46
|
+
"This is your only way to sign in, so it can't be disconnected.";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Settings panel for the sign-in identities attached to an account: lists the
|
|
50
|
+
* connected ones, offers connect buttons for the rest, and guards the last
|
|
51
|
+
* remaining method from being disconnected — the guard is a disabled button
|
|
52
|
+
* plus an explanation rather than an error after the fact.
|
|
53
|
+
*/
|
|
54
|
+
export function LinkedAccountsBlock({
|
|
55
|
+
title = "Connected accounts",
|
|
56
|
+
description = "Manage the accounts you can use to sign in.",
|
|
57
|
+
identities = [],
|
|
58
|
+
providers = ["google", "apple", "github"],
|
|
59
|
+
loading = false,
|
|
60
|
+
onLink,
|
|
61
|
+
onUnlink,
|
|
62
|
+
className,
|
|
63
|
+
}: LinkedAccountsBlockProps) {
|
|
64
|
+
const noteId = React.useId();
|
|
65
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
66
|
+
const [linking, setLinking] = React.useState<SocialProvider | null>(null);
|
|
67
|
+
const [unlinking, setUnlinking] = React.useState<string | null>(null);
|
|
68
|
+
|
|
69
|
+
const connected = new Set(identities.map((identity) => identity.provider));
|
|
70
|
+
const connectable = providers.filter((provider) => !connected.has(provider));
|
|
71
|
+
const busy = linking !== null || unlinking !== null;
|
|
72
|
+
// The backend rule, enforced here so we never fire a request that would
|
|
73
|
+
// remove the account's only way in.
|
|
74
|
+
const lastMethod = identities.length <= 1;
|
|
75
|
+
|
|
76
|
+
async function connect(provider: SocialProvider) {
|
|
77
|
+
if (busy) return;
|
|
78
|
+
setError(null);
|
|
79
|
+
setLinking(provider);
|
|
80
|
+
try {
|
|
81
|
+
await onLink?.(provider);
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
setError(
|
|
84
|
+
cause instanceof Error ? cause.message : "Unable to connect account.",
|
|
85
|
+
);
|
|
86
|
+
} finally {
|
|
87
|
+
setLinking(null);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function disconnect(id: string) {
|
|
92
|
+
if (busy || lastMethod) return;
|
|
93
|
+
setError(null);
|
|
94
|
+
setUnlinking(id);
|
|
95
|
+
try {
|
|
96
|
+
await onUnlink?.(id);
|
|
97
|
+
} catch (cause) {
|
|
98
|
+
setError(
|
|
99
|
+
cause instanceof Error
|
|
100
|
+
? cause.message
|
|
101
|
+
: "Unable to disconnect account.",
|
|
102
|
+
);
|
|
103
|
+
} finally {
|
|
104
|
+
setUnlinking(null);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<MotionConfig reducedMotion="user">
|
|
110
|
+
<Card className={cn("w-full", className)} data-slot="auth-block">
|
|
111
|
+
<CardHeader>
|
|
112
|
+
<CardTitle>{title}</CardTitle>
|
|
113
|
+
<CardDescription>{description}</CardDescription>
|
|
114
|
+
</CardHeader>
|
|
115
|
+
<CardPanel>
|
|
116
|
+
{loading ? (
|
|
117
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
118
|
+
<Spinner className="size-4" />
|
|
119
|
+
Loading connected accounts…
|
|
120
|
+
</div>
|
|
121
|
+
) : (
|
|
122
|
+
<motion.div
|
|
123
|
+
layout
|
|
124
|
+
transition={{ duration: 0.3, ease: EASE }}
|
|
125
|
+
className="flex flex-col gap-3"
|
|
126
|
+
>
|
|
127
|
+
<AuthError message={error} />
|
|
128
|
+
|
|
129
|
+
{identities.length > 0 ? (
|
|
130
|
+
<ul
|
|
131
|
+
aria-label="Connected accounts"
|
|
132
|
+
className="flex flex-col gap-2"
|
|
133
|
+
>
|
|
134
|
+
<AnimatePresence initial={false}>
|
|
135
|
+
{identities.map((identity, index) => {
|
|
136
|
+
const { label, Icon } = SOCIAL_PROVIDERS[
|
|
137
|
+
identity.provider
|
|
138
|
+
];
|
|
139
|
+
return (
|
|
140
|
+
<motion.li
|
|
141
|
+
key={identity.id}
|
|
142
|
+
layout
|
|
143
|
+
className="flex items-center justify-between gap-3 rounded-lg border px-3 py-2"
|
|
144
|
+
initial={{ opacity: 0, y: -4 }}
|
|
145
|
+
animate={{ opacity: 1, y: 0 }}
|
|
146
|
+
exit={{ opacity: 0, y: -4 }}
|
|
147
|
+
// 40ms per row, capped so a long identity list never
|
|
148
|
+
// leaves the last row arriving noticeably late.
|
|
149
|
+
transition={{
|
|
150
|
+
duration: 0.25,
|
|
151
|
+
ease: EASE,
|
|
152
|
+
delay: Math.min(index, 5) * 0.04,
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
<div className="flex min-w-0 items-center gap-2.5">
|
|
156
|
+
<Icon
|
|
157
|
+
aria-hidden="true"
|
|
158
|
+
className="size-4 text-muted-foreground"
|
|
159
|
+
/>
|
|
160
|
+
<div className="flex min-w-0 flex-col">
|
|
161
|
+
<span className="font-medium text-sm">
|
|
162
|
+
{label}
|
|
163
|
+
</span>
|
|
164
|
+
{identity.email && (
|
|
165
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
166
|
+
{identity.email}
|
|
167
|
+
</span>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
<Button
|
|
172
|
+
aria-describedby={lastMethod ? noteId : undefined}
|
|
173
|
+
aria-label={`Disconnect ${label}`}
|
|
174
|
+
size="sm"
|
|
175
|
+
type="button"
|
|
176
|
+
variant="destructive-outline"
|
|
177
|
+
disabled={busy || lastMethod}
|
|
178
|
+
loading={unlinking === identity.id}
|
|
179
|
+
onClick={() => void disconnect(identity.id)}
|
|
180
|
+
>
|
|
181
|
+
Disconnect
|
|
182
|
+
</Button>
|
|
183
|
+
</motion.li>
|
|
184
|
+
);
|
|
185
|
+
})}
|
|
186
|
+
</AnimatePresence>
|
|
187
|
+
</ul>
|
|
188
|
+
) : (
|
|
189
|
+
<p className="text-sm text-muted-foreground">
|
|
190
|
+
No sign-in methods connected yet.
|
|
191
|
+
</p>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
{lastMethod && identities.length > 0 && (
|
|
195
|
+
<p className="text-xs text-muted-foreground" id={noteId}>
|
|
196
|
+
{LAST_METHOD_EXPLANATION}
|
|
197
|
+
</p>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
{connectable.map((provider) => {
|
|
201
|
+
const { label, Icon } = SOCIAL_PROVIDERS[provider];
|
|
202
|
+
return (
|
|
203
|
+
<Button
|
|
204
|
+
key={provider}
|
|
205
|
+
className="w-full"
|
|
206
|
+
type="button"
|
|
207
|
+
variant="outline"
|
|
208
|
+
disabled={busy}
|
|
209
|
+
loading={linking === provider}
|
|
210
|
+
onClick={() => void connect(provider)}
|
|
211
|
+
>
|
|
212
|
+
<Icon aria-hidden="true" />
|
|
213
|
+
<span className="flex-1">Connect {label}</span>
|
|
214
|
+
</Button>
|
|
215
|
+
);
|
|
216
|
+
})}
|
|
217
|
+
</motion.div>
|
|
218
|
+
)}
|
|
219
|
+
</CardPanel>
|
|
220
|
+
</Card>
|
|
221
|
+
</MotionConfig>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
7
|
+
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
|
|
8
|
+
import { Input } from "@/components/ui/input";
|
|
9
|
+
import { Label } from "@/components/ui/label";
|
|
10
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
11
|
+
import { cn } from "@/lib/utils";
|
|
12
|
+
import type {
|
|
13
|
+
OnboardingFieldConfig,
|
|
14
|
+
OnboardingStepConfig,
|
|
15
|
+
OnboardingValue,
|
|
16
|
+
} from "../onboarding-block";
|
|
17
|
+
|
|
18
|
+
export interface ProfileStepProps {
|
|
19
|
+
step: OnboardingStepConfig;
|
|
20
|
+
/** Seed values, so drafts survive back/forward navigation. */
|
|
21
|
+
values: Record<string, OnboardingValue>;
|
|
22
|
+
submitting?: boolean;
|
|
23
|
+
submitLabel: string;
|
|
24
|
+
onSubmit: (values: Record<string, OnboardingValue>) => void | Promise<void>;
|
|
25
|
+
onBack?: () => void;
|
|
26
|
+
/** Reports every edit so the flow can restore drafts across navigation. */
|
|
27
|
+
onDraftChange?: (name: string, value: OnboardingValue) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function initialValue(
|
|
31
|
+
field: OnboardingFieldConfig,
|
|
32
|
+
saved: OnboardingValue | undefined,
|
|
33
|
+
): OnboardingValue {
|
|
34
|
+
if (field.kind === "checkbox") return typeof saved === "boolean" ? saved : false;
|
|
35
|
+
return typeof saved === "string" ? saved : "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Built-in validation, mirroring the declared `FieldConfig` contract. */
|
|
39
|
+
function validateField(
|
|
40
|
+
field: OnboardingFieldConfig,
|
|
41
|
+
value: OnboardingValue,
|
|
42
|
+
): string | null {
|
|
43
|
+
const required = field.required === true;
|
|
44
|
+
|
|
45
|
+
if (field.kind === "checkbox") {
|
|
46
|
+
if (required && value !== true) return `${field.label} is required`;
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
51
|
+
if (required && text.length === 0) return `${field.label} is required`;
|
|
52
|
+
|
|
53
|
+
if (field.kind === "url" && text.length > 0) {
|
|
54
|
+
try {
|
|
55
|
+
new URL(text);
|
|
56
|
+
} catch {
|
|
57
|
+
return "Enter a valid URL";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (field.kind === "select" && text.length > 0) {
|
|
62
|
+
const allowed = field.options.map((option) => option.value);
|
|
63
|
+
if (!allowed.includes(text)) {
|
|
64
|
+
return `Choose a valid option for ${field.label}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return field.validate?.(text) ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Renders one declared profile step: `OnboardingFieldConfig[]` → labeled
|
|
73
|
+
* controls, validated locally before anything is handed to `onSubmit`. Mounted
|
|
74
|
+
* keyed by `step.id` so drafts re-seed per step.
|
|
75
|
+
*/
|
|
76
|
+
export function ProfileStep({
|
|
77
|
+
step,
|
|
78
|
+
values,
|
|
79
|
+
submitting = false,
|
|
80
|
+
submitLabel,
|
|
81
|
+
onSubmit,
|
|
82
|
+
onBack,
|
|
83
|
+
onDraftChange,
|
|
84
|
+
}: ProfileStepProps) {
|
|
85
|
+
const idPrefix = React.useId();
|
|
86
|
+
const [draft, setDraft] = React.useState<Record<string, OnboardingValue>>(
|
|
87
|
+
() =>
|
|
88
|
+
Object.fromEntries(
|
|
89
|
+
step.fields.map((field) => [
|
|
90
|
+
field.name,
|
|
91
|
+
initialValue(field, values[field.name]),
|
|
92
|
+
]),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
|
96
|
+
|
|
97
|
+
function setValue(name: string, value: OnboardingValue) {
|
|
98
|
+
setDraft((current) => ({ ...current, [name]: value }));
|
|
99
|
+
onDraftChange?.(name, value);
|
|
100
|
+
// Clear a field's error as soon as it is edited; re-validation happens on
|
|
101
|
+
// the next submit rather than on every keystroke.
|
|
102
|
+
setErrors((current) =>
|
|
103
|
+
current[name] ? { ...current, [name]: "" } : current,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
if (submitting) return;
|
|
110
|
+
|
|
111
|
+
const next: Record<string, string> = {};
|
|
112
|
+
for (const field of step.fields) {
|
|
113
|
+
const message = validateField(field, draft[field.name]);
|
|
114
|
+
if (message) next[field.name] = message;
|
|
115
|
+
}
|
|
116
|
+
if (Object.keys(next).length > 0) {
|
|
117
|
+
setErrors(next);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
setErrors({});
|
|
121
|
+
|
|
122
|
+
const submitted: Record<string, OnboardingValue> = {};
|
|
123
|
+
for (const field of step.fields) {
|
|
124
|
+
const value = draft[field.name];
|
|
125
|
+
submitted[field.name] = typeof value === "string" ? value.trim() : value;
|
|
126
|
+
}
|
|
127
|
+
await onSubmit(submitted);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<form className="flex flex-col gap-4" noValidate onSubmit={handleSubmit}>
|
|
132
|
+
{step.fields.map((field) => {
|
|
133
|
+
const error = errors[field.name] || undefined;
|
|
134
|
+
const controlId = `${idPrefix}-${field.name}`;
|
|
135
|
+
const value = draft[field.name];
|
|
136
|
+
|
|
137
|
+
if (field.kind === "checkbox") {
|
|
138
|
+
return (
|
|
139
|
+
<Field
|
|
140
|
+
key={field.name}
|
|
141
|
+
name={field.name}
|
|
142
|
+
invalid={Boolean(error)}
|
|
143
|
+
className="gap-1.5"
|
|
144
|
+
>
|
|
145
|
+
<div className="flex items-center gap-2">
|
|
146
|
+
<Checkbox
|
|
147
|
+
id={controlId}
|
|
148
|
+
checked={value === true}
|
|
149
|
+
disabled={submitting}
|
|
150
|
+
onCheckedChange={(checked) => setValue(field.name, checked)}
|
|
151
|
+
/>
|
|
152
|
+
<Label htmlFor={controlId} className="font-normal">
|
|
153
|
+
{field.label}
|
|
154
|
+
</Label>
|
|
155
|
+
</div>
|
|
156
|
+
{error && <FieldError match>{error}</FieldError>}
|
|
157
|
+
</Field>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<Field key={field.name} name={field.name} invalid={Boolean(error)}>
|
|
163
|
+
<FieldLabel htmlFor={controlId}>{field.label}</FieldLabel>
|
|
164
|
+
{field.kind === "textarea" ? (
|
|
165
|
+
<Textarea
|
|
166
|
+
id={controlId}
|
|
167
|
+
aria-invalid={error ? true : undefined}
|
|
168
|
+
disabled={submitting}
|
|
169
|
+
placeholder={field.placeholder}
|
|
170
|
+
value={String(value ?? "")}
|
|
171
|
+
onChange={(event) => setValue(field.name, event.target.value)}
|
|
172
|
+
/>
|
|
173
|
+
) : field.kind === "select" ? (
|
|
174
|
+
// No select atom exists in the library yet; a native control
|
|
175
|
+
// styled to match Input keeps the flow self-contained without
|
|
176
|
+
// inventing one here.
|
|
177
|
+
<select
|
|
178
|
+
id={controlId}
|
|
179
|
+
aria-invalid={error ? true : undefined}
|
|
180
|
+
className={cn(
|
|
181
|
+
"h-8.5 w-full rounded-lg border border-input bg-background px-[calc(--spacing(3)-1px)] text-base text-foreground shadow-xs/5 outline-none transition-[box-shadow,border-color] duration-150 ease-smooth-out focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24 disabled:opacity-64 aria-invalid:border-destructive/36 sm:h-7.5 sm:text-sm dark:bg-input/32",
|
|
182
|
+
)}
|
|
183
|
+
disabled={submitting}
|
|
184
|
+
value={String(value ?? "")}
|
|
185
|
+
onChange={(event) => setValue(field.name, event.target.value)}
|
|
186
|
+
>
|
|
187
|
+
<option value="">{field.placeholder ?? "Choose an option"}</option>
|
|
188
|
+
{field.options.map((option) => (
|
|
189
|
+
<option key={option.value} value={option.value}>
|
|
190
|
+
{option.label}
|
|
191
|
+
</option>
|
|
192
|
+
))}
|
|
193
|
+
</select>
|
|
194
|
+
) : (
|
|
195
|
+
<Input
|
|
196
|
+
id={controlId}
|
|
197
|
+
aria-invalid={error ? true : undefined}
|
|
198
|
+
disabled={submitting}
|
|
199
|
+
placeholder={field.placeholder}
|
|
200
|
+
type={field.kind === "url" ? "url" : "text"}
|
|
201
|
+
value={String(value ?? "")}
|
|
202
|
+
onChange={(event) => setValue(field.name, event.target.value)}
|
|
203
|
+
/>
|
|
204
|
+
)}
|
|
205
|
+
{error && <FieldError match>{error}</FieldError>}
|
|
206
|
+
</Field>
|
|
207
|
+
);
|
|
208
|
+
})}
|
|
209
|
+
|
|
210
|
+
<div className="flex gap-2">
|
|
211
|
+
{onBack && (
|
|
212
|
+
<Button
|
|
213
|
+
type="button"
|
|
214
|
+
variant="outline"
|
|
215
|
+
disabled={submitting}
|
|
216
|
+
onClick={onBack}
|
|
217
|
+
>
|
|
218
|
+
Back
|
|
219
|
+
</Button>
|
|
220
|
+
)}
|
|
221
|
+
<Button className="flex-1" type="submit" loading={submitting}>
|
|
222
|
+
{submitLabel}
|
|
223
|
+
</Button>
|
|
224
|
+
</div>
|
|
225
|
+
</form>
|
|
226
|
+
);
|
|
227
|
+
}
|