@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exegia/corpora-ui",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "shadcn-ready React UI library for the corpora manuscript-research apps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- const [terms, setTerms] = React.useState(false);
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) => setTerms(checked === true)}
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
- <Button
236
- variant="link"
237
- type="button"
238
- onClick={onTerms}
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
  )}