@gntik-ai/blocks 0.5.0 → 0.7.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/index.d.ts +337 -97
- package/dist/index.js +1311 -384
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/auth/SignUpForm/SignUpForm.configured.test.tsx +316 -0
- package/src/auth/SignUpForm/SignUpForm.tsx +194 -50
- package/src/auth/SignUpForm/block.meta.ts +15 -2
- package/src/auth/SignUpForm/examples/CustomFields.tsx +56 -0
- package/src/auth/SignUpForm/examples/Error.tsx +5 -0
- package/src/auth/SignUpForm/examples/Loading.tsx +6 -0
- package/src/auth/SignUpForm/examples/NoTerms.tsx +6 -0
- package/src/auth/SignUpForm/examples/Submitting.tsx +5 -0
- package/src/auth/SignUpForm/examples/Unavailable.tsx +15 -0
- package/src/auth/index.ts +1 -0
- package/src/auth/shared.tsx +34 -0
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.examples.test.tsx +25 -0
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.extended.test.tsx +252 -0
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.focus.test.tsx +178 -0
- package/src/feedback/ConfirmDestructive/ConfirmDestructive.tsx +219 -28
- package/src/feedback/ConfirmDestructive/block.meta.ts +2 -2
- package/src/feedback/ConfirmDestructive/examples/FailureStates.tsx +39 -0
- package/src/feedback/ConfirmDestructive/examples/PlainDeletion.tsx +6 -0
- package/src/feedback/ConfirmDestructive/examples/SecretRevocation.tsx +34 -0
- package/src/feedback/EmptyStates/EmptyStates.test.tsx +11 -1
- package/src/feedback/EmptyStates/EmptyStates.tsx +31 -1
- package/src/feedback/EmptyStates/block.meta.ts +1 -1
- package/src/feedback/EmptyStates/fixtures.ts +5 -0
- package/src/feedback/ErrorPanel/ErrorPanel.test.tsx +7 -0
- package/src/feedback/ErrorPanel/ErrorPanel.tsx +8 -3
- package/src/meta.ts +2 -0
- package/src/tables/CursorPagerFooter/CursorPagerFooter.examples.test.tsx +33 -0
- package/src/tables/CursorPagerFooter/CursorPagerFooter.test.tsx +51 -0
- package/src/tables/CursorPagerFooter/CursorPagerFooter.tsx +10 -0
- package/src/tables/CursorPagerFooter/block.meta.ts +23 -0
- package/src/tables/CursorPagerFooter/examples/Busy.tsx +7 -0
- package/src/tables/CursorPagerFooter/examples/CursorStack.tsx +40 -0
- package/src/tables/CursorPagerFooter/examples/Hidden.tsx +16 -0
- package/src/tables/CursorPagerFooter/examples/RowsPerPage.tsx +21 -0
- package/src/tables/CursorPagerFooter/fixtures.ts +13 -0
- package/src/tables/DataTable/DataTable.labels.test.tsx +291 -0
- package/src/tables/DataTable/DataTable.overflow-labels.test.tsx +59 -0
- package/src/tables/DataTable/DataTable.server-pagination.test.tsx +173 -0
- package/src/tables/DataTable/DataTable.tsx +40 -21
- package/src/tables/DataTable/block.meta.ts +104 -1
- package/src/tables/DataTable/column-menu.tsx +10 -7
- package/src/tables/DataTable/data-table-body.tsx +3 -2
- package/src/tables/DataTable/data-table-props.ts +30 -4
- package/src/tables/DataTable/data-table-row.tsx +15 -4
- package/src/tables/DataTable/density-toggle.tsx +7 -4
- package/src/tables/DataTable/editable-cell.tsx +21 -8
- package/src/tables/DataTable/examples/server-pagination.tsx +68 -0
- package/src/tables/DataTable/header-cell.tsx +4 -0
- package/src/tables/DataTable/labels.ts +54 -0
- package/src/tables/DataTable/resize-handle.tsx +6 -3
- package/src/tables/DataTable/views.tsx +21 -14
- package/src/tables/FilterBar/FilterBar.labels.test.tsx +69 -0
- package/src/tables/FilterBar/FilterBar.tsx +45 -12
- package/src/tables/FilterBar/block.meta.ts +22 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.announcements.test.tsx +106 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.examples.test.tsx +36 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.labels.test.tsx +71 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.test.tsx +184 -0
- package/src/tables/LoadMoreFooter/LoadMoreFooter.tsx +151 -0
- package/src/tables/LoadMoreFooter/block.meta.ts +28 -0
- package/src/tables/LoadMoreFooter/examples/cards.tsx +22 -0
- package/src/tables/LoadMoreFooter/examples/cursor.tsx +25 -0
- package/src/tables/LoadMoreFooter/examples/exhausted.tsx +6 -0
- package/src/tables/LoadMoreFooter/examples/failed.tsx +13 -0
- package/src/tables/LoadMoreFooter/examples/flag.tsx +33 -0
- package/src/tables/LoadMoreFooter/examples/useMockCursor.ts +30 -0
- package/src/tables/LoadMoreFooter/fixtures.ts +9 -0
- package/src/tables/LoadMoreFooter/useLoadMoreFooter.ts +56 -0
- package/src/tables/PaginationFooter/PaginationFooter.labels.test.tsx +57 -0
- package/src/tables/PaginationFooter/PaginationFooter.tsx +31 -7
- package/src/tables/PaginationFooter/block.meta.ts +23 -0
- package/src/tables/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gntik-ai/blocks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"@gntik-ai/flow": "0.5.0",
|
|
41
41
|
"@gntik-ai/icons": "0.5.0",
|
|
42
42
|
"@gntik-ai/tokens": "0.2.1",
|
|
43
|
-
"@gntik-ai/ui": "0.
|
|
43
|
+
"@gntik-ai/ui": "0.7.0",
|
|
44
44
|
"lucide-react": "^1.48.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
@@ -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 {
|
|
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 {
|
|
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
|
-
/**
|
|
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 =
|
|
60
|
-
|
|
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 (
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
{
|
|
121
|
-
<
|
|
122
|
-
|
|
123
|
-
</
|
|
242
|
+
{passwordHelp != null && (
|
|
243
|
+
<FieldDescription>
|
|
244
|
+
{typeof passwordHelp === 'function' ? passwordHelp(minPasswordLength) : passwordHelp}
|
|
245
|
+
</FieldDescription>
|
|
124
246
|
)}
|
|
125
|
-
|
|
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:
|
|
8
|
-
|
|
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
|
};
|