@gntik-ai/blocks 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/index.d.ts +163 -31
  2. package/dist/index.js +663 -157
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/src/auth/SignUpForm/SignUpForm.configured.test.tsx +316 -0
  6. package/src/auth/SignUpForm/SignUpForm.tsx +194 -50
  7. package/src/auth/SignUpForm/block.meta.ts +15 -2
  8. package/src/auth/SignUpForm/examples/CustomFields.tsx +56 -0
  9. package/src/auth/SignUpForm/examples/Error.tsx +5 -0
  10. package/src/auth/SignUpForm/examples/Loading.tsx +6 -0
  11. package/src/auth/SignUpForm/examples/NoTerms.tsx +6 -0
  12. package/src/auth/SignUpForm/examples/Submitting.tsx +5 -0
  13. package/src/auth/SignUpForm/examples/Unavailable.tsx +15 -0
  14. package/src/auth/index.ts +1 -0
  15. package/src/auth/shared.tsx +34 -0
  16. package/src/meta.ts +2 -0
  17. package/src/tables/DataTable/DataTable.labels.test.tsx +291 -0
  18. package/src/tables/DataTable/DataTable.overflow-labels.test.tsx +59 -0
  19. package/src/tables/DataTable/DataTable.tsx +20 -9
  20. package/src/tables/DataTable/block.meta.ts +102 -0
  21. package/src/tables/DataTable/column-menu.tsx +10 -7
  22. package/src/tables/DataTable/data-table-body.tsx +3 -2
  23. package/src/tables/DataTable/data-table-props.ts +3 -0
  24. package/src/tables/DataTable/data-table-row.tsx +15 -4
  25. package/src/tables/DataTable/density-toggle.tsx +7 -4
  26. package/src/tables/DataTable/editable-cell.tsx +21 -8
  27. package/src/tables/DataTable/header-cell.tsx +4 -0
  28. package/src/tables/DataTable/labels.ts +54 -0
  29. package/src/tables/DataTable/resize-handle.tsx +6 -3
  30. package/src/tables/DataTable/views.tsx +21 -14
  31. package/src/tables/FilterBar/FilterBar.labels.test.tsx +69 -0
  32. package/src/tables/FilterBar/FilterBar.tsx +45 -12
  33. package/src/tables/FilterBar/block.meta.ts +22 -0
  34. package/src/tables/PaginationFooter/PaginationFooter.labels.test.tsx +57 -0
  35. package/src/tables/PaginationFooter/PaginationFooter.tsx +31 -7
  36. package/src/tables/PaginationFooter/block.meta.ts +23 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gntik-ai/blocks",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "gntik-ui blocks: page chrome, data display, tables, forms, feedback, billing, team and builder sections composed from @gntik-ai/ui.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,316 @@
1
+ import { render, screen, within, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { expectNoAxeViolations } from '../../test/a11y';
4
+ import { SignUpForm } from './SignUpForm';
5
+
6
+ const fields = [
7
+ {
8
+ id: 'username',
9
+ label: 'Username',
10
+ placeholder: 'Account name',
11
+ help: 'Choose an account name.',
12
+ required: true,
13
+ type: 'text' as const,
14
+ autoComplete: 'username',
15
+ },
16
+ {
17
+ id: 'displayName',
18
+ label: 'Display name',
19
+ placeholder: 'Your name',
20
+ help: 'Name shown to others.',
21
+ required: true,
22
+ type: 'text' as const,
23
+ autoComplete: 'name',
24
+ },
25
+ {
26
+ id: 'email',
27
+ label: 'Email',
28
+ placeholder: 'you@example.com',
29
+ help: 'For account notifications.',
30
+ required: true,
31
+ type: 'email' as const,
32
+ autoComplete: 'email',
33
+ },
34
+ {
35
+ id: 'tenant',
36
+ label: 'Tenant identifier',
37
+ placeholder: 'Tenant',
38
+ help: 'Your organization.',
39
+ required: true,
40
+ type: 'text' as const,
41
+ autoComplete: 'off',
42
+ },
43
+ {
44
+ id: 'workspace',
45
+ label: 'Workspace identifier',
46
+ placeholder: 'Workspace',
47
+ help: 'Optional workspace.',
48
+ required: false,
49
+ type: 'text' as const,
50
+ autoComplete: 'off',
51
+ },
52
+ ];
53
+
54
+ describe('SignUpForm configured fields', () => {
55
+ it('blocks whitespace-only required fields independently of password and consent, and refocuses the summary on retry', async () => {
56
+ const onSubmit = vi.fn();
57
+ render(
58
+ <SignUpForm
59
+ fields={[
60
+ {
61
+ id: 'username',
62
+ label: 'Username',
63
+ required: true,
64
+ help: 'Choose an account name.',
65
+ messages: { required: 'An account name is required.' },
66
+ },
67
+ { id: 'workspace', label: 'Workspace identifier' },
68
+ ]}
69
+ terms={false}
70
+ sso={null}
71
+ onSubmit={onSubmit}
72
+ />,
73
+ );
74
+ await userEvent.type(screen.getByLabelText('Username'), ' ');
75
+ await userEvent.type(screen.getByLabelText('Password'), 'correct-horse-42');
76
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }));
77
+ expect(onSubmit).not.toHaveBeenCalled();
78
+ const summary = screen.getByRole('region', { name: 'There is 1 problem with this form' });
79
+ expect(summary).toHaveFocus();
80
+ await userEvent.tab();
81
+ expect(within(summary).getByRole('link', { name: 'Username: An account name is required.' })).toHaveFocus();
82
+ await userEvent.keyboard('{Enter}');
83
+ expect(screen.getByLabelText('Username')).toHaveFocus();
84
+ expect(screen.getByLabelText('Username')).toHaveAccessibleDescription(
85
+ 'Choose an account name. An account name is required.',
86
+ );
87
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }));
88
+ expect(summary).toHaveFocus();
89
+ expect(onSubmit).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it('renders ordered fields, links all errors to inputs, and submits trimmed values with an empty optional field', async () => {
93
+ const onSubmit = vi.fn();
94
+ const { container } = render(<SignUpForm fields={fields} onSubmit={onSubmit} sso={null} />);
95
+ expect(
96
+ Array.from(container.querySelectorAll('input[type="text"], input[type="email"], input[type="password"]')).map(
97
+ (input) => input.getAttribute('placeholder'),
98
+ ),
99
+ ).toEqual(['Account name', 'Your name', 'you@example.com', 'Tenant', 'Workspace', null]);
100
+ for (const field of fields) {
101
+ const input = screen.getByLabelText(field.label);
102
+ expect(input).toHaveAttribute('type', field.type);
103
+ expect(input).toHaveAttribute('autocomplete', field.autoComplete);
104
+ expect(input).toHaveAccessibleDescription(field.help);
105
+ expect(input).toHaveProperty('required', field.required);
106
+ }
107
+ await expectNoAxeViolations(container);
108
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }));
109
+ expect(onSubmit).not.toHaveBeenCalled();
110
+ const summary = screen.getByRole('region');
111
+ expect(summary).toHaveFocus();
112
+ expect(within(summary).getAllByRole('link')).toHaveLength(6);
113
+ await userEvent.click(within(summary).getByRole('link', { name: 'Username: Enter Username.' }));
114
+ expect(screen.getByLabelText('Username')).toHaveFocus();
115
+ expect(screen.getByLabelText('Username')).toHaveAttribute('aria-invalid', 'true');
116
+ expect(screen.getByLabelText('Username')).toHaveAccessibleDescription('Choose an account name. Enter Username.');
117
+ await expectNoAxeViolations(container);
118
+ await userEvent.type(screen.getByLabelText('Username'), ' avery ');
119
+ await userEvent.type(screen.getByLabelText('Display name'), ' Avery Collins ');
120
+ await userEvent.type(screen.getByLabelText('Email'), ' avery@example.com ');
121
+ await userEvent.type(screen.getByLabelText('Tenant identifier'), ' team ');
122
+ await userEvent.type(screen.getByLabelText('Password'), 'correct-horse-42');
123
+ await userEvent.click(screen.getByRole('checkbox'));
124
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }));
125
+ expect(onSubmit).toHaveBeenCalledWith({
126
+ name: '',
127
+ email: 'avery@example.com',
128
+ password: 'correct-horse-42',
129
+ fields: {
130
+ username: 'avery',
131
+ displayName: 'Avery Collins',
132
+ email: 'avery@example.com',
133
+ tenant: 'team',
134
+ workspace: '',
135
+ },
136
+ });
137
+ });
138
+ });
139
+
140
+ it('omits consent only when terms is explicitly false, in default and configured modes', async () => {
141
+ const onSubmit = vi.fn();
142
+ const { rerender } = render(<SignUpForm terms={false} sso={null} onSubmit={onSubmit} />);
143
+ expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
144
+ await userEvent.type(screen.getByLabelText('Full name'), ' Avery ');
145
+ await userEvent.type(screen.getByLabelText('Work email'), 'avery@example.com');
146
+ await userEvent.type(screen.getByLabelText('Password'), 'correct-horse-42');
147
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }));
148
+ expect(onSubmit).toHaveBeenCalledWith({ name: 'Avery', email: 'avery@example.com', password: 'correct-horse-42' });
149
+ onSubmit.mockClear();
150
+ rerender(<SignUpForm fields={[]} terms={false} sso={null} onSubmit={onSubmit} />);
151
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }));
152
+ expect(onSubmit).toHaveBeenCalledWith({ name: '', email: '', password: 'correct-horse-42', fields: {} });
153
+ });
154
+
155
+ it.each([undefined, 8, 12])(
156
+ 'uses the password minimum %s for inline validation, optional help and the meter',
157
+ async (minimum) => {
158
+ const onSubmit = vi.fn();
159
+ const min = minimum ?? 8;
160
+ const { container } = render(
161
+ <SignUpForm
162
+ fields={[]}
163
+ terms={false}
164
+ minPasswordLength={minimum}
165
+ passwordHelp={`Use at least ${min} characters to secure your account.`}
166
+ onSubmit={onSubmit}
167
+ sso={null}
168
+ />,
169
+ );
170
+ const password = screen.getByLabelText('Password');
171
+ expect(password).toHaveAccessibleDescription(
172
+ expect.stringContaining(`Use at least ${min} characters to secure your account.`),
173
+ );
174
+ await userEvent.type(password, min === 8 ? 'Abc1!xy' : 'Abc1!xyzabc');
175
+ expect(password).toHaveValue(min === 8 ? 'Abc1!xy' : 'Abc1!xyzabc');
176
+ expect(screen.getByText('Too weak')).toBeInTheDocument();
177
+ expect(screen.getByText(`Use at least ${min} characters.`)).toBeInTheDocument();
178
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }));
179
+ expect(onSubmit).not.toHaveBeenCalled();
180
+ expect(password).toHaveAttribute('aria-invalid', 'true');
181
+ await expectNoAxeViolations(container);
182
+ await userEvent.type(password, 'z');
183
+ expect(password).toHaveValue(min === 8 ? 'Abc1!xyz' : 'Abc1!xyzabcz');
184
+ await waitFor(() => expect(screen.queryByText(`Use at least ${min} characters.`)).not.toBeInTheDocument());
185
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }));
186
+ expect(onSubmit).toHaveBeenCalledTimes(1);
187
+ },
188
+ );
189
+
190
+ it('replaces the form with an unavailable callout and consumer action', async () => {
191
+ const onAction = vi.fn();
192
+ const { container } = render(
193
+ <SignUpForm
194
+ unavailable={{
195
+ title: 'Registration unavailable',
196
+ message: 'Contact your administrator.',
197
+ action: <button onClick={onAction}>Request access</button>,
198
+ }}
199
+ />,
200
+ );
201
+ expect(screen.getByText('Registration unavailable')).toBeInTheDocument();
202
+ expect(screen.getByText('Contact your administrator.')).toBeInTheDocument();
203
+ expect(screen.queryByRole('form')).not.toBeInTheDocument();
204
+ expect(container.querySelector('input')).toBeNull();
205
+ expect(screen.queryByRole('button', { name: 'Create account' })).not.toBeInTheDocument();
206
+ await userEvent.click(screen.getByRole('button', { name: 'Request access' }));
207
+ expect(onAction).toHaveBeenCalledTimes(1);
208
+ await expectNoAxeViolations(container);
209
+ });
210
+
211
+ it('shows skeletons and cannot submit while policy is loading, then restores the form', async () => {
212
+ const onSubmit = vi.fn();
213
+ const { container, rerender } = render(<SignUpForm policyLoading onSubmit={onSubmit} />);
214
+ expect(screen.getByRole('status')).toHaveTextContent('Loading sign-up policy');
215
+ expect(container.querySelectorAll('[aria-hidden="true"]').length).toBeGreaterThan(0);
216
+ expect(screen.queryByRole('form')).not.toBeInTheDocument();
217
+ expect(container.querySelector('input')).toBeNull();
218
+ expect(screen.queryByRole('button', { name: 'Create account' })).not.toBeInTheDocument();
219
+ expect(onSubmit).not.toHaveBeenCalled();
220
+ await expectNoAxeViolations(container);
221
+ rerender(<SignUpForm policyLoading={false} onSubmit={onSubmit} />);
222
+ expect(screen.getByRole('form')).toBeInTheDocument();
223
+ });
224
+
225
+ it('validates non-empty optional email and uses consumer-supplied messages', async () => {
226
+ const onSubmit = vi.fn();
227
+ render(
228
+ <SignUpForm
229
+ fields={[
230
+ {
231
+ id: 'contact',
232
+ label: 'Contact',
233
+ type: 'email',
234
+ help: 'Optional contact address.',
235
+ messages: { invalid: 'Check the contact address.' },
236
+ },
237
+ ]}
238
+ terms={false}
239
+ messages={{ passwordTooShort: (minimum) => `Password needs ${minimum} characters.` }}
240
+ onSubmit={onSubmit}
241
+ sso={null}
242
+ />,
243
+ );
244
+ await userEvent.type(screen.getByLabelText('Contact'), 'invalid');
245
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }));
246
+ const summary = screen.getByRole('region');
247
+ expect(within(summary).getByRole('link', { name: 'Contact: Check the contact address.' })).toBeInTheDocument();
248
+ await userEvent.click(within(summary).getByRole('link', { name: 'Password: Password needs 8 characters.' }));
249
+ expect(screen.getByLabelText('Password')).toHaveFocus();
250
+ expect(onSubmit).not.toHaveBeenCalled();
251
+ });
252
+
253
+ it('keeps password help, strength and error descriptions linked and focuses consent from the summary', async () => {
254
+ const { container } = render(
255
+ <SignUpForm fields={[]} passwordHelp="Choose at least 8 characters to secure your account." sso={null} />,
256
+ );
257
+ const password = screen.getByLabelText('Password');
258
+ await userEvent.type(password, 'Abc1!xy');
259
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }));
260
+ expect(password).toHaveAccessibleDescription(
261
+ 'Strength: Too weak Choose at least 8 characters to secure your account. Use at least 8 characters.',
262
+ );
263
+ const summary = screen.getByRole('region');
264
+ await userEvent.click(within(summary).getByRole('link', { name: 'Password: Use at least 8 characters.' }));
265
+ expect(password).toHaveFocus();
266
+ await userEvent.click(screen.getByRole('button', { name: 'Show password' }));
267
+ expect(password).toHaveAttribute('type', 'text');
268
+ expect(password).toHaveAttribute('aria-invalid', 'true');
269
+ expect(password).toHaveAccessibleDescription(
270
+ 'Strength: Too weak Choose at least 8 characters to secure your account. Use at least 8 characters.',
271
+ );
272
+ const termsLink = within(summary).getByRole('link', { name: 'Terms: Accept the terms to continue.' });
273
+ const termsTarget = termsLink.getAttribute('href')!.slice(1);
274
+ expect(document.getElementById(termsTarget)).toBe(screen.getByRole('checkbox'));
275
+ expect(container.querySelectorAll(`[id="${termsTarget}"]`)).toHaveLength(1);
276
+ termsLink.focus();
277
+ await userEvent.keyboard('{Enter}');
278
+ expect(screen.getByRole('checkbox')).toHaveFocus();
279
+ expect(screen.getByRole('checkbox')).toHaveAccessibleDescription('Accept the terms to continue.');
280
+ await userEvent.keyboard(' ');
281
+ expect(screen.getByRole('checkbox')).toBeChecked();
282
+ expect(within(summary).queryByRole('link', { name: 'Terms: Accept the terms to continue.' })).not.toBeInTheDocument();
283
+ });
284
+
285
+ it('treats field ids as consumer keys, including names inherited by ordinary objects', async () => {
286
+ const onSubmit = vi.fn();
287
+ render(
288
+ <SignUpForm
289
+ fields={[{ id: 'constructor', label: 'Account identifier', required: true }]}
290
+ terms={false}
291
+ sso={null}
292
+ onSubmit={onSubmit}
293
+ />,
294
+ );
295
+ expect(screen.getByLabelText('Account identifier')).toHaveValue('');
296
+ await userEvent.type(screen.getByLabelText('Account identifier'), ' account ');
297
+ await userEvent.type(screen.getByLabelText('Password'), 'correct-horse-42');
298
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }));
299
+ expect(onSubmit).toHaveBeenCalledWith({
300
+ name: '',
301
+ email: '',
302
+ password: 'correct-horse-42',
303
+ fields: { constructor: 'account' },
304
+ });
305
+ });
306
+
307
+ it('preserves default submit-time validation and omits a validation summary in default mode', async () => {
308
+ render(<SignUpForm sso={null} />);
309
+ await userEvent.type(screen.getByLabelText('Password'), 'Abc1!xy');
310
+ expect(screen.getByText('Too weak')).toBeInTheDocument();
311
+ expect(screen.queryByText('Use at least 8 characters.')).not.toBeInTheDocument();
312
+ expect(screen.getByLabelText('Password')).not.toHaveAttribute('aria-invalid', 'true');
313
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }));
314
+ expect(screen.getByText('Use at least 8 characters.')).toBeInTheDocument();
315
+ expect(screen.queryByRole('region')).not.toBeInTheDocument();
316
+ });
@@ -1,22 +1,59 @@
1
- import { Alert, Button, Checkbox, Field, FieldError, FieldLabel, Input, Link, PasswordInput, cn } from '@gntik-ai/ui';
1
+ import {
2
+ Alert,
3
+ Button,
4
+ Checkbox,
5
+ Field,
6
+ FieldDescription,
7
+ FieldError,
8
+ FieldLabel,
9
+ Input,
10
+ Link,
11
+ PasswordInput,
12
+ Skeleton,
13
+ cn,
14
+ } from '@gntik-ai/ui';
2
15
  import { Mail, User } from '@gntik-ai/icons';
3
16
  import { useId, useState, type MouseEvent, type ReactNode } from 'react';
4
17
  import { SsoButtons } from '../SsoButtons/SsoButtons';
5
- import { AuthDivider, AuthHeader, EMAIL_RE, authFormClass, useAsyncSubmit } from '../shared';
18
+ import {
19
+ AuthDivider,
20
+ AuthHeader,
21
+ EMAIL_RE,
22
+ authFormClass,
23
+ authSignUpFieldError,
24
+ useAsyncSubmit,
25
+ type AuthSignUpField,
26
+ type AuthUnavailableState,
27
+ type AuthSignUpValidationMessages,
28
+ } from '../shared';
29
+ import { ValidationSummary, type ValidationIssue } from '../../forms/ValidationSummary/ValidationSummary';
6
30
 
7
31
  export interface SignUpValues {
8
32
  name: string;
9
33
  email: string;
10
34
  password: string;
35
+ /** Present only in configured mode. Text/email values are trimmed and keyed by field id. */
36
+ fields?: Record<string, string>;
11
37
  }
12
38
 
13
39
  export interface SignUpFormProps {
40
+ /** Ordered text/email inputs before Password. Omit to keep the default fields and payload. */
41
+ fields?: readonly AuthSignUpField[];
42
+ messages?: AuthSignUpValidationMessages;
43
+ /** Replaces all account creation controls with a consumer-supplied callout. */
44
+ unavailable?: AuthUnavailableState;
45
+ /** Resolving policy, separate from loading (an account submission). No form is rendered. */
46
+ policyLoading?: boolean;
14
47
  /** Creates the account. A returned promise shows the loading state; a rejection shows in the alert. */
15
48
  onSubmit?: (values: SignUpValues) => void | Promise<void>;
16
49
  error?: string | null;
17
50
  loading?: boolean;
18
- /** Minimum password length. */
51
+ /** Optional password guidance; receives the current minimum length when a function is supplied. */
52
+ passwordHelp?: ReactNode | ((minimum: number) => ReactNode);
53
+ /** Minimum password length. Defaults to 8. */
19
54
  minPasswordLength?: number;
55
+ /** Omit to require consent; false removes the checkbox and consent validation. */
56
+ terms?: false;
20
57
  termsHref?: string;
21
58
  privacyHref?: string;
22
59
  signInHref?: string;
@@ -31,10 +68,16 @@ export interface SignUpFormProps {
31
68
 
32
69
  /** Account creation: name, work email, password with a strength meter and a required terms checkbox. */
33
70
  export function SignUpForm({
71
+ fields,
72
+ messages,
73
+ unavailable,
74
+ policyLoading = false,
34
75
  onSubmit,
35
76
  error: errorProp,
36
77
  loading: loadingProp,
37
78
  minPasswordLength = 8,
79
+ passwordHelp,
80
+ terms: termsProp,
38
81
  termsHref = '#',
39
82
  privacyHref = '#',
40
83
  signInHref = '#',
@@ -46,6 +89,16 @@ export function SignUpForm({
46
89
  className,
47
90
  }: SignUpFormProps) {
48
91
  const termsErrorId = useId();
92
+ const fieldPrefix = useId();
93
+ const passwordId = `${fieldPrefix}-password`;
94
+ const termsId = `${fieldPrefix}-terms`;
95
+ const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
96
+ const [submitCount, setSubmitCount] = useState(0);
97
+ const fieldValue = (id: string) => (Object.hasOwn(fieldValues, id) ? (fieldValues[id] ?? '') : '');
98
+ const configuredErrors: ValidationIssue[] = (fields ?? []).flatMap((field) => {
99
+ const message = authSignUpFieldError(fieldValue(field.id), field);
100
+ return message ? [{ fieldId: `${fieldPrefix}-field-${field.id}`, label: field.label, message }] : [];
101
+ });
49
102
  const [name, setName] = useState('');
50
103
  const [email, setEmail] = useState('');
51
104
  const [password, setPassword] = useState('');
@@ -56,8 +109,41 @@ export function SignUpForm({
56
109
  const alert = errorProp ?? error;
57
110
  const nameInvalid = submitted && name.trim() === '';
58
111
  const emailInvalid = submitted && !EMAIL_RE.test(email.trim());
59
- const passwordInvalid = submitted && password.length < minPasswordLength;
60
- const termsInvalid = submitted && !terms;
112
+ const passwordInvalid =
113
+ (submitted || (fields !== undefined && password.length > 0)) && password.length < minPasswordLength;
114
+ const termsInvalid = submitted && termsProp !== false && !terms;
115
+
116
+ const passwordError =
117
+ messages?.passwordTooShort?.(minPasswordLength) ?? `Use at least ${minPasswordLength} characters.`;
118
+ const termsError = messages?.termsRequired ?? 'Accept the terms to continue.';
119
+ const summaryErrors = [
120
+ ...configuredErrors,
121
+ ...(password.length < minPasswordLength
122
+ ? [{ fieldId: passwordId, label: 'Password', message: passwordError }]
123
+ : []),
124
+ ...(termsProp !== false && !terms ? [{ fieldId: termsId, label: 'Terms', message: termsError }] : []),
125
+ ];
126
+
127
+ if (unavailable)
128
+ return (
129
+ <div className={cn(authFormClass, className)}>
130
+ <Alert tone="info" title={unavailable.title} description={unavailable.message} actions={unavailable.action} />
131
+ </div>
132
+ );
133
+
134
+ if (policyLoading)
135
+ return (
136
+ <div className={cn(authFormClass, 'flex flex-col gap-4', className)}>
137
+ <span role="status" className="sr-only">
138
+ Loading sign-up policy…
139
+ </span>
140
+ <Skeleton aria-hidden className="h-8 w-3/4" />
141
+ <Skeleton aria-hidden className="h-4 w-full" />
142
+ <Skeleton aria-hidden className="h-12 w-full" />
143
+ <Skeleton aria-hidden className="h-12 w-full" />
144
+ <Skeleton aria-hidden className="h-12 w-full" />
145
+ </div>
146
+ );
61
147
 
62
148
  return (
63
149
  <div className={cn(authFormClass, className)}>
@@ -69,60 +155,118 @@ export function SignUpForm({
69
155
  onSubmit={(e) => {
70
156
  e.preventDefault();
71
157
  setSubmitted(true);
72
- if (name.trim() === '' || !EMAIL_RE.test(email.trim()) || password.length < minPasswordLength || !terms)
158
+ if (fields !== undefined) {
159
+ setSubmitCount((count) => count + 1);
160
+ if (summaryErrors.length > 0) return;
161
+ const values = Object.fromEntries(fields.map((field) => [field.id, fieldValue(field.id).trim()]));
162
+ void run(() =>
163
+ onSubmit?.({ name: values.name ?? '', email: values.email ?? '', password, fields: values }),
164
+ );
165
+ return;
166
+ }
167
+ if (
168
+ name.trim() === '' ||
169
+ !EMAIL_RE.test(email.trim()) ||
170
+ password.length < minPasswordLength ||
171
+ (termsProp !== false && !terms)
172
+ )
73
173
  return;
74
174
  void run(() => onSubmit?.({ name: name.trim(), email: email.trim(), password }));
75
175
  }}
76
176
  >
77
177
  {alert && <Alert tone="destructive" description={alert} />}
78
- <Field invalid={nameInvalid}>
79
- <FieldLabel>Full name</FieldLabel>
80
- <Input
81
- leadingIcon={User}
82
- autoComplete="name"
83
- placeholder="Avery Collins"
84
- value={name}
85
- onValueChange={setName}
86
- required
87
- />
88
- <FieldError match={nameInvalid}>Enter your name.</FieldError>
89
- </Field>
90
- <Field invalid={emailInvalid}>
91
- <FieldLabel>Work email</FieldLabel>
92
- <Input
93
- type="email"
94
- leadingIcon={Mail}
95
- autoComplete="email"
96
- placeholder="name@company.com"
97
- value={email}
98
- onValueChange={setEmail}
99
- required
100
- />
101
- <FieldError match={emailInvalid}>Enter a valid email address.</FieldError>
102
- </Field>
178
+ {fields !== undefined && submitted && (
179
+ // Destructive text needs an untinted surface to meet contrast in the light theme.
180
+ <ValidationSummary errors={summaryErrors} submitCount={submitCount} className="bg-background" />
181
+ )}
182
+ {fields !== undefined ? (
183
+ fields.map((field) => {
184
+ const message = authSignUpFieldError(fieldValue(field.id), field);
185
+ const invalid = submitted && message !== null;
186
+ return (
187
+ <Field key={field.id} invalid={invalid}>
188
+ <FieldLabel>{field.label}</FieldLabel>
189
+ <Input
190
+ id={`${fieldPrefix}-field-${field.id}`}
191
+ type={field.type ?? 'text'}
192
+ autoComplete={field.autoComplete}
193
+ placeholder={field.placeholder}
194
+ required={field.required}
195
+ value={fieldValue(field.id)}
196
+ onValueChange={(value) => setFieldValues((values) => ({ ...values, [field.id]: value }))}
197
+ />
198
+ {field.help != null && <FieldDescription>{field.help}</FieldDescription>}
199
+ <FieldError match={invalid}>{message}</FieldError>
200
+ </Field>
201
+ );
202
+ })
203
+ ) : (
204
+ <>
205
+ <Field invalid={nameInvalid}>
206
+ <FieldLabel>Full name</FieldLabel>
207
+ <Input
208
+ leadingIcon={User}
209
+ autoComplete="name"
210
+ placeholder="Avery Collins"
211
+ value={name}
212
+ onValueChange={setName}
213
+ required
214
+ />
215
+ <FieldError match={nameInvalid}>Enter your name.</FieldError>
216
+ </Field>
217
+ <Field invalid={emailInvalid}>
218
+ <FieldLabel>Work email</FieldLabel>
219
+ <Input
220
+ type="email"
221
+ leadingIcon={Mail}
222
+ autoComplete="email"
223
+ placeholder="name@company.com"
224
+ value={email}
225
+ onValueChange={setEmail}
226
+ required
227
+ />
228
+ <FieldError match={emailInvalid}>Enter a valid email address.</FieldError>
229
+ </Field>
230
+ </>
231
+ )}
103
232
  <Field invalid={passwordInvalid}>
104
233
  <FieldLabel>Password</FieldLabel>
105
- <PasswordInput showStrength value={password} onValueChange={setPassword} required />
106
- <FieldError match={passwordInvalid}>Use at least {minPasswordLength} characters.</FieldError>
107
- </Field>
108
- <div>
109
- <Checkbox
110
- checked={terms}
111
- onCheckedChange={setTerms}
112
- aria-invalid={termsInvalid || undefined}
113
- aria-describedby={termsInvalid ? termsErrorId : undefined}
114
- label={
115
- <>
116
- I agree to the <Link href={termsHref}>Terms</Link> and <Link href={privacyHref}>Privacy Policy</Link>.
117
- </>
118
- }
234
+ <PasswordInput
235
+ id={fields !== undefined ? passwordId : undefined}
236
+ showStrength
237
+ strength={password.length > 0 && password.length < minPasswordLength ? 0 : undefined}
238
+ value={password}
239
+ onValueChange={setPassword}
240
+ required
119
241
  />
120
- {termsInvalid && (
121
- <p id={termsErrorId} className="mt-1.5 text-[12.5px] text-destructive-text">
122
- Accept the terms to continue.
123
- </p>
242
+ {passwordHelp != null && (
243
+ <FieldDescription>
244
+ {typeof passwordHelp === 'function' ? passwordHelp(minPasswordLength) : passwordHelp}
245
+ </FieldDescription>
124
246
  )}
125
- </div>
247
+ <FieldError match={passwordInvalid}>{passwordError}</FieldError>
248
+ </Field>
249
+ {termsProp !== false && (
250
+ <div>
251
+ <Checkbox
252
+ render={fields !== undefined ? <span id={termsId} /> : undefined}
253
+ checked={terms}
254
+ onCheckedChange={setTerms}
255
+ aria-invalid={termsInvalid || undefined}
256
+ aria-describedby={termsInvalid ? termsErrorId : undefined}
257
+ label={
258
+ <>
259
+ I agree to the <Link href={termsHref}>Terms</Link> and <Link href={privacyHref}>Privacy Policy</Link>.
260
+ </>
261
+ }
262
+ />
263
+ {termsInvalid && (
264
+ <p id={termsErrorId} className="mt-1.5 text-[12.5px] text-destructive-text">
265
+ {termsError}
266
+ </p>
267
+ )}
268
+ </div>
269
+ )}
126
270
  <Button type="submit" size="lg" loading={busy} className="mt-2 w-full">
127
271
  Create account
128
272
  </Button>
@@ -4,6 +4,19 @@ export const meta: BlockMeta = {
4
4
  name: 'Sign-up form',
5
5
  family: 'auth',
6
6
  status: 'stable',
7
- description: 'Account creation with name, email, a password strength meter and a required terms checkbox.',
8
- uses: ['Field', 'Input', 'PasswordInput', 'Checkbox', 'Button', 'Alert', 'Link', 'SsoButtons'],
7
+ description:
8
+ 'Account creation with default name/email fields or ordered text/email fields (values.fields), a password strength meter and required consent (terms=false opts out). minPasswordLength defaults to 8 and drives length validation and the insufficient-length meter; optional passwordHelp can receive the minimum. Configured fields support help, required, autoComplete and validation messages, with a linked validation summary on invalid submit. policyLoading renders skeletons without a form; unavailable replaces the form with consumer title, message and optional action. loading remains submitting; error is form-level feedback. Examples: custom-fields, no-terms, unavailable, loading, submitting and error.',
9
+ uses: [
10
+ 'Field',
11
+ 'Input',
12
+ 'PasswordInput',
13
+ 'Checkbox',
14
+ 'Button',
15
+ 'Alert',
16
+ 'Link',
17
+ 'SsoButtons',
18
+ 'Skeleton',
19
+ 'FieldDescription',
20
+ 'ValidationSummary',
21
+ ],
9
22
  };