@exegia/corpora-ui 0.7.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__/signup-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/signup-block.d.ts +16 -1
- package/dist-lib/index.js +329 -319
- package/dist-lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/blocks/auth/__tests__/signup-block.test.tsx +176 -0
- package/src/components/blocks/auth/signup-block.tsx +43 -10
package/package.json
CHANGED
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { SignupBlock } from "../signup-block";
|
|
7
|
+
|
|
8
|
+
const STRONG = "Str0ngPassword";
|
|
9
|
+
|
|
10
|
+
/** Fills every field, so only the terms checkbox stands between us and submit. */
|
|
11
|
+
async function fillForm(user: ReturnType<typeof userEvent.setup>) {
|
|
12
|
+
await user.type(screen.getByLabelText("Name"), "Ada Researcher");
|
|
13
|
+
await user.type(screen.getByLabelText("Email"), "ada@corpora.local");
|
|
14
|
+
await user.type(await screen.findByLabelText("Password"), STRONG);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Base UI renders a hidden native input beside the Checkbox root, so the box is
|
|
18
|
+
// queried by role and read from aria-checked (see CLAUDE.md).
|
|
19
|
+
const checkbox = () => screen.getByRole("checkbox");
|
|
20
|
+
|
|
21
|
+
describe("SignupBlock terms checkbox", () => {
|
|
22
|
+
test("owns the box when uncontrolled", async () => {
|
|
23
|
+
const user = userEvent.setup();
|
|
24
|
+
render(<SignupBlock />);
|
|
25
|
+
|
|
26
|
+
expect(checkbox().getAttribute("aria-checked")).toBe("false");
|
|
27
|
+
|
|
28
|
+
await user.click(checkbox());
|
|
29
|
+
await waitFor(() =>
|
|
30
|
+
expect(checkbox().getAttribute("aria-checked")).toBe("true"),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("starts from defaultTermsChecked", () => {
|
|
35
|
+
render(<SignupBlock defaultTermsChecked />);
|
|
36
|
+
expect(checkbox().getAttribute("aria-checked")).toBe("true");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("reports changes through onTermsCheckedChange while uncontrolled", async () => {
|
|
40
|
+
const user = userEvent.setup();
|
|
41
|
+
const onChange = mock((checked: boolean) => checked);
|
|
42
|
+
render(<SignupBlock onTermsCheckedChange={onChange} />);
|
|
43
|
+
|
|
44
|
+
await user.click(checkbox());
|
|
45
|
+
expect(onChange).toHaveBeenCalledWith(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("defers to the owner when controlled", async () => {
|
|
49
|
+
const user = userEvent.setup();
|
|
50
|
+
const onChange = mock((checked: boolean) => checked);
|
|
51
|
+
render(<SignupBlock termsChecked={false} onTermsCheckedChange={onChange} />);
|
|
52
|
+
|
|
53
|
+
await user.click(checkbox());
|
|
54
|
+
|
|
55
|
+
// The click is reported, but the box only moves if the owner says so.
|
|
56
|
+
expect(onChange).toHaveBeenCalledWith(true);
|
|
57
|
+
expect(checkbox().getAttribute("aria-checked")).toBe("false");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("lets an outside action tick the box and unblock submit", async () => {
|
|
61
|
+
const user = userEvent.setup();
|
|
62
|
+
const onSubmit = mock(async () => {});
|
|
63
|
+
|
|
64
|
+
// Stands in for a consumer's terms dialog: an "I agree" action that ticks
|
|
65
|
+
// the block's checkbox from outside it.
|
|
66
|
+
function Host() {
|
|
67
|
+
const [accepted, setAccepted] = React.useState(false);
|
|
68
|
+
return (
|
|
69
|
+
<>
|
|
70
|
+
<button type="button" onClick={() => setAccepted(true)}>
|
|
71
|
+
I agree
|
|
72
|
+
</button>
|
|
73
|
+
<SignupBlock
|
|
74
|
+
termsChecked={accepted}
|
|
75
|
+
onTermsCheckedChange={setAccepted}
|
|
76
|
+
onSubmit={onSubmit}
|
|
77
|
+
/>
|
|
78
|
+
</>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
render(<Host />);
|
|
82
|
+
|
|
83
|
+
await fillForm(user);
|
|
84
|
+
await user.click(await screen.findByRole("button", { name: "I agree" }));
|
|
85
|
+
await waitFor(() =>
|
|
86
|
+
expect(checkbox().getAttribute("aria-checked")).toBe("true"),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
await user.click(screen.getByRole("button", { name: "Create account" }));
|
|
90
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("still blocks submission while the controlled box is unticked", async () => {
|
|
94
|
+
const user = userEvent.setup();
|
|
95
|
+
const onSubmit = mock(async () => {});
|
|
96
|
+
render(<SignupBlock termsChecked={false} onSubmit={onSubmit} />);
|
|
97
|
+
|
|
98
|
+
await fillForm(user);
|
|
99
|
+
await user.click(screen.getByRole("button", { name: "Create account" }));
|
|
100
|
+
|
|
101
|
+
expect(await screen.findByRole("alert")).toHaveProperty(
|
|
102
|
+
"textContent",
|
|
103
|
+
"Please accept the terms to continue.",
|
|
104
|
+
);
|
|
105
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("SignupBlock field labelling", () => {
|
|
110
|
+
test("names every field, including the revealed password", async () => {
|
|
111
|
+
const user = userEvent.setup();
|
|
112
|
+
render(<SignupBlock />);
|
|
113
|
+
|
|
114
|
+
expect(screen.getByLabelText("Name")).toBeDefined();
|
|
115
|
+
expect(screen.getByLabelText("Email")).toBeDefined();
|
|
116
|
+
|
|
117
|
+
// The password field only appears once the email validates.
|
|
118
|
+
await user.type(screen.getByLabelText("Email"), "ada@corpora.local");
|
|
119
|
+
expect(await screen.findByLabelText("Password")).toBeDefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("gives each instance its own password id", async () => {
|
|
123
|
+
const user = userEvent.setup();
|
|
124
|
+
render(
|
|
125
|
+
<>
|
|
126
|
+
<SignupBlock showNameField={false} />
|
|
127
|
+
<SignupBlock showNameField={false} />
|
|
128
|
+
</>,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const emails = screen.getAllByLabelText("Email");
|
|
132
|
+
await user.type(emails[0], "ada@corpora.local");
|
|
133
|
+
await user.type(emails[1], "ben@corpora.local");
|
|
134
|
+
|
|
135
|
+
// Two blocks on one page must not collide on a hard-coded id, or the
|
|
136
|
+
// second label would point at the first block's input.
|
|
137
|
+
const passwords = await screen.findAllByLabelText("Password");
|
|
138
|
+
expect(passwords).toHaveLength(2);
|
|
139
|
+
expect(passwords[0].id).not.toBe(passwords[1].id);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// The terms link sits inside the consent <label>, and under happy-dom that
|
|
144
|
+
// nesting computes an empty accessible name — so it is queried by text rather
|
|
145
|
+
// than by role + name. It resolves normally in a browser.
|
|
146
|
+
const termsLink = () => screen.getByText("terms");
|
|
147
|
+
|
|
148
|
+
describe("SignupBlock termsComponent", () => {
|
|
149
|
+
test("renders the built-in terms link by default", async () => {
|
|
150
|
+
const user = userEvent.setup();
|
|
151
|
+
const onTerms = mock(() => {});
|
|
152
|
+
render(<SignupBlock onTerms={onTerms} />);
|
|
153
|
+
|
|
154
|
+
expect(termsLink().tagName).toBe("BUTTON");
|
|
155
|
+
await user.click(termsLink());
|
|
156
|
+
expect(onTerms).toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("swaps the link for the consumer's own node", () => {
|
|
160
|
+
render(
|
|
161
|
+
<SignupBlock
|
|
162
|
+
termsComponent={<button type="button">read the terms</button>}
|
|
163
|
+
/>,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(screen.getByText("read the terms")).toBeDefined();
|
|
167
|
+
expect(screen.queryByText("terms")).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("keeps the consent label around the replacement", () => {
|
|
171
|
+
render(<SignupBlock termsComponent={<span>our terms of use</span>} />);
|
|
172
|
+
|
|
173
|
+
expect(screen.getByText(/I agree to the/)).toBeDefined();
|
|
174
|
+
expect(screen.getByRole("checkbox")).toBeDefined();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -31,6 +31,11 @@ import {
|
|
|
31
31
|
export interface SignupBlockProps {
|
|
32
32
|
title?: string;
|
|
33
33
|
description?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Replaces the built-in "terms" link inside the consent label — pass your
|
|
36
|
+
* own dialog trigger to render it inline instead of wiring `onTerms`.
|
|
37
|
+
*/
|
|
38
|
+
termsComponent?: React.ReactNode;
|
|
34
39
|
/** Brand mark rendered above the title. Omit for no logo row at all. */
|
|
35
40
|
logo?: React.ReactNode;
|
|
36
41
|
/** Brand accent for the primary action. Omit to keep the default primary. */
|
|
@@ -39,6 +44,16 @@ export interface SignupBlockProps {
|
|
|
39
44
|
showNameField?: boolean;
|
|
40
45
|
/** Require the terms checkbox before submitting. */
|
|
41
46
|
showTerms?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Controls the terms checkbox. Pass it with `onTermsCheckedChange` when
|
|
49
|
+
* something outside the block has to tick the box — an "I agree" action in
|
|
50
|
+
* your own terms dialog, say. Omit to let the block own the state.
|
|
51
|
+
*/
|
|
52
|
+
termsChecked?: boolean;
|
|
53
|
+
/** Starting state of the terms checkbox while it is uncontrolled. */
|
|
54
|
+
defaultTermsChecked?: boolean;
|
|
55
|
+
/** Fires on every change, controlled or not. */
|
|
56
|
+
onTermsCheckedChange?: (checked: boolean) => void;
|
|
42
57
|
/** Block submission until every password requirement is met. */
|
|
43
58
|
enforceStrongPassword?: boolean;
|
|
44
59
|
onSubmit?: (data: {
|
|
@@ -59,21 +74,36 @@ export function SignupBlock({
|
|
|
59
74
|
providers = ["google", "apple", "github"],
|
|
60
75
|
showNameField = true,
|
|
61
76
|
showTerms = true,
|
|
77
|
+
termsChecked,
|
|
78
|
+
defaultTermsChecked = false,
|
|
79
|
+
onTermsCheckedChange,
|
|
62
80
|
enforceStrongPassword = true,
|
|
63
81
|
onSubmit,
|
|
82
|
+
termsComponent,
|
|
64
83
|
onProviderSelect,
|
|
65
84
|
onLogin,
|
|
66
85
|
onTerms,
|
|
67
86
|
}: SignupBlockProps) {
|
|
68
87
|
const nameId = React.useId();
|
|
69
88
|
const emailId = React.useId();
|
|
89
|
+
const passwordId = React.useId();
|
|
70
90
|
const termsId = React.useId();
|
|
71
91
|
const [status, setStatus] = React.useState<AuthStatus>("idle");
|
|
72
92
|
const [error, setError] = React.useState<string | null>(null);
|
|
73
93
|
const [loadingProvider, setLoadingProvider] =
|
|
74
94
|
React.useState<SocialProvider | null>(null);
|
|
75
95
|
const [password, setPassword] = React.useState("");
|
|
76
|
-
|
|
96
|
+
// Controlled when `termsChecked` is passed, uncontrolled otherwise. The
|
|
97
|
+
// internal state is kept either way, so a block that switches between the
|
|
98
|
+
// two mid-life does not lose the box, and the callback fires in both modes.
|
|
99
|
+
const [uncontrolledTerms, setUncontrolledTerms] =
|
|
100
|
+
React.useState(defaultTermsChecked);
|
|
101
|
+
const terms = termsChecked ?? uncontrolledTerms;
|
|
102
|
+
|
|
103
|
+
function handleTermsChange(checked: boolean) {
|
|
104
|
+
if (termsChecked === undefined) setUncontrolledTerms(checked);
|
|
105
|
+
onTermsCheckedChange?.(checked);
|
|
106
|
+
}
|
|
77
107
|
// Tracked from each input's own constraint validation rather than a regex.
|
|
78
108
|
// The values stay uncontrolled — only validity is needed, and the form reads
|
|
79
109
|
// values from FormData on submit.
|
|
@@ -198,6 +228,7 @@ export function SignupBlock({
|
|
|
198
228
|
id={emailId}
|
|
199
229
|
name="email"
|
|
200
230
|
type="email"
|
|
231
|
+
aria-label="Email"
|
|
201
232
|
autoComplete="email"
|
|
202
233
|
placeholder="you@example.com"
|
|
203
234
|
required
|
|
@@ -209,9 +240,11 @@ export function SignupBlock({
|
|
|
209
240
|
</Field>
|
|
210
241
|
<Reveal show={emailValid}>
|
|
211
242
|
<Field name="password">
|
|
212
|
-
<FieldLabel>Password</FieldLabel>
|
|
243
|
+
<FieldLabel htmlFor={passwordId}>Password</FieldLabel>
|
|
213
244
|
<PasswordInput
|
|
245
|
+
id={passwordId}
|
|
214
246
|
name="password"
|
|
247
|
+
aria-label="Password"
|
|
215
248
|
autoComplete="new-password"
|
|
216
249
|
placeholder="Create a password"
|
|
217
250
|
showStrength
|
|
@@ -227,18 +260,18 @@ export function SignupBlock({
|
|
|
227
260
|
<Checkbox
|
|
228
261
|
id={termsId}
|
|
229
262
|
checked={terms}
|
|
230
|
-
onCheckedChange={(checked) =>
|
|
263
|
+
onCheckedChange={(checked) =>
|
|
264
|
+
handleTermsChange(checked === true)
|
|
265
|
+
}
|
|
231
266
|
disabled={busy}
|
|
232
267
|
/>
|
|
233
268
|
<Label htmlFor={termsId} className="font-normal">
|
|
234
269
|
I agree to the{" "}
|
|
235
|
-
|
|
236
|
-
variant="link"
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
terms
|
|
241
|
-
</Button>
|
|
270
|
+
{termsComponent ?? (
|
|
271
|
+
<Button variant="link" type="button" onClick={onTerms}>
|
|
272
|
+
terms
|
|
273
|
+
</Button>
|
|
274
|
+
)}
|
|
242
275
|
</Label>
|
|
243
276
|
</div>
|
|
244
277
|
)}
|