@jmruthers/pace-core 0.5.1 → 0.5.4
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/{DataTable-GX3XERFJ.js → DataTable-ZQDRE46Q.js} +7 -6
- package/dist/{PublicLoadingSpinner-DztrzuJr.d.ts → PublicLoadingSpinner-Bq_-BeK-.d.ts} +1 -1
- package/dist/RBACProvider-BO4ilsQB.d.ts +63 -0
- package/dist/{UnifiedAuthProvider-w66zSCUf.d.ts → UnifiedAuthProvider-DGQsy-vY.d.ts} +2 -59
- package/dist/{api-ETQ6YJ3C.js → api-H5A3H4IR.js} +2 -2
- package/dist/{chunk-T3XIA4AJ.js → chunk-5H3C2SWM.js} +14 -16
- package/dist/chunk-5H3C2SWM.js.map +1 -0
- package/dist/chunk-5SIXIV7R.js +1925 -0
- package/dist/chunk-5SIXIV7R.js.map +1 -0
- package/dist/chunk-GNTALZV3.js +17 -0
- package/dist/chunk-GNTALZV3.js.map +1 -0
- package/dist/{chunk-C5G2A4PO.js → chunk-GWSBHC4J.js} +6 -6
- package/dist/{chunk-XJK2J4N6.js → chunk-HD7PYDUV.js} +4 -6
- package/dist/{chunk-XJK2J4N6.js.map → chunk-HD7PYDUV.js.map} +1 -1
- package/dist/{chunk-TGDCLPP2.js → chunk-HXX35Q2M.js} +6 -21
- package/dist/chunk-HXX35Q2M.js.map +1 -0
- package/dist/{chunk-5EL3KHOQ.js → chunk-K6B7BLSE.js} +2 -2
- package/dist/{chunk-GSNM5D6H.js → chunk-M4RW7PIP.js} +4 -4
- package/dist/{chunk-U6JDHVC2.js → chunk-PVMYVQSM.js} +6 -8
- package/dist/{chunk-U6JDHVC2.js.map → chunk-PVMYVQSM.js.map} +1 -1
- package/dist/{chunk-6CR3MRZN.js → chunk-QKHFMQ5R.js} +372 -11
- package/dist/{chunk-6CR3MRZN.js.map → chunk-QKHFMQ5R.js.map} +1 -1
- package/dist/chunk-QVYBYGT2.js +428 -0
- package/dist/chunk-QVYBYGT2.js.map +1 -0
- package/dist/{chunk-OEGRKULD.js → chunk-WJARTBCT.js} +56 -1
- package/dist/chunk-WJARTBCT.js.map +1 -0
- package/dist/components.d.ts +4 -3
- package/dist/components.js +16 -162
- package/dist/components.js.map +1 -1
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +7 -9
- package/dist/hooks.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.js +152 -17
- package/dist/index.js.map +1 -1
- package/dist/providers.d.ts +3 -2
- package/dist/providers.js +6 -12
- package/dist/rbac/index.d.ts +167 -98
- package/dist/rbac/index.js +48 -1881
- package/dist/rbac/index.js.map +1 -1
- package/dist/styles/core.css +0 -55
- package/dist/types.d.ts +2 -2
- package/dist/{unified-CM7T0aTK.d.ts → unified-CMPjE_fv.d.ts} +1 -1
- package/dist/{usePublicRouteParams-B6i0KtXW.d.ts → usePublicRouteParams-B2OcAsur.d.ts} +1 -1
- package/dist/utils.js +12 -14
- package/dist/utils.js.map +1 -1
- package/docs/api/classes/ErrorBoundary.md +1 -1
- package/docs/api/classes/InvalidScopeError.md +73 -0
- package/docs/api/classes/MissingUserContextError.md +66 -0
- package/docs/api/classes/OrganisationContextRequiredError.md +66 -0
- package/docs/api/classes/PermissionDeniedError.md +73 -0
- package/docs/api/classes/PublicErrorBoundary.md +1 -1
- package/docs/api/classes/RBACAuditManager.md +270 -0
- package/docs/api/classes/RBACCache.md +284 -0
- package/docs/api/classes/RBACEngine.md +141 -0
- package/docs/api/classes/RBACError.md +76 -0
- package/docs/api/classes/RBACNotInitializedError.md +66 -0
- package/docs/api/classes/SecureSupabaseClient.md +135 -0
- package/docs/api/interfaces/AggregateConfig.md +1 -1
- package/docs/api/interfaces/ButtonProps.md +1 -1
- package/docs/api/interfaces/CardProps.md +1 -1
- package/docs/api/interfaces/ColorPalette.md +1 -1
- package/docs/api/interfaces/ColorShade.md +1 -1
- package/docs/api/interfaces/DataAccessRecord.md +96 -0
- package/docs/api/interfaces/DataTableAction.md +1 -1
- package/docs/api/interfaces/DataTableColumn.md +1 -1
- package/docs/api/interfaces/DataTableProps.md +1 -1
- package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
- package/docs/api/interfaces/EmptyStateConfig.md +1 -1
- package/docs/api/interfaces/EnhancedNavigationMenuProps.md +235 -0
- package/docs/api/interfaces/EventContextType.md +1 -1
- package/docs/api/interfaces/EventLogoProps.md +1 -1
- package/docs/api/interfaces/EventProviderProps.md +1 -1
- package/docs/api/interfaces/FileSizeLimits.md +1 -1
- package/docs/api/interfaces/FileUploadProps.md +1 -1
- package/docs/api/interfaces/FooterProps.md +1 -1
- package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
- package/docs/api/interfaces/InputProps.md +1 -1
- package/docs/api/interfaces/LabelProps.md +1 -1
- package/docs/api/interfaces/LoginFormProps.md +1 -1
- package/docs/api/interfaces/NavigationAccessRecord.md +107 -0
- package/docs/api/interfaces/NavigationContextType.md +164 -0
- package/docs/api/interfaces/NavigationGuardProps.md +139 -0
- package/docs/api/interfaces/NavigationItem.md +1 -1
- package/docs/api/interfaces/NavigationMenuProps.md +1 -1
- package/docs/api/interfaces/NavigationProviderProps.md +117 -0
- package/docs/api/interfaces/Organisation.md +1 -1
- package/docs/api/interfaces/OrganisationContextType.md +1 -1
- package/docs/api/interfaces/OrganisationMembership.md +2 -2
- package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
- package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
- package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
- package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
- package/docs/api/interfaces/PageAccessRecord.md +85 -0
- package/docs/api/interfaces/PagePermissionContextType.md +140 -0
- package/docs/api/interfaces/PagePermissionGuardProps.md +153 -0
- package/docs/api/interfaces/PagePermissionProviderProps.md +119 -0
- package/docs/api/interfaces/PaletteData.md +1 -1
- package/docs/api/interfaces/PermissionEnforcerProps.md +153 -0
- package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
- package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
- package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
- package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
- package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
- package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
- package/docs/api/interfaces/RBACConfig.md +99 -0
- package/docs/api/interfaces/RBACContextType.md +474 -0
- package/docs/api/interfaces/RBACLogger.md +112 -0
- package/docs/api/interfaces/RBACProviderProps.md +107 -0
- package/docs/api/interfaces/RoleBasedRouterContextType.md +151 -0
- package/docs/api/interfaces/RoleBasedRouterProps.md +156 -0
- package/docs/api/interfaces/RouteAccessRecord.md +107 -0
- package/docs/api/interfaces/RouteConfig.md +121 -0
- package/docs/api/interfaces/SecureDataContextType.md +168 -0
- package/docs/api/interfaces/SecureDataProviderProps.md +132 -0
- package/docs/api/interfaces/StorageConfig.md +1 -1
- package/docs/api/interfaces/StorageFileInfo.md +1 -1
- package/docs/api/interfaces/StorageFileMetadata.md +1 -1
- package/docs/api/interfaces/StorageListOptions.md +1 -1
- package/docs/api/interfaces/StorageListResult.md +1 -1
- package/docs/api/interfaces/StorageUploadOptions.md +1 -1
- package/docs/api/interfaces/StorageUploadResult.md +1 -1
- package/docs/api/interfaces/StorageUrlOptions.md +1 -1
- package/docs/api/interfaces/StyleImport.md +1 -1
- package/docs/api/interfaces/ToastActionElement.md +1 -1
- package/docs/api/interfaces/ToastProps.md +1 -1
- package/docs/api/interfaces/UnifiedAuthContextType.md +85 -85
- package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
- package/docs/api/interfaces/UsePublicEventLogoOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
- package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
- package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
- package/docs/api/interfaces/UserEventAccess.md +11 -11
- package/docs/api/interfaces/UserMenuProps.md +1 -1
- package/docs/api/interfaces/UserProfile.md +1 -1
- package/docs/api/modules.md +2244 -3
- package/docs/migration-guide.md +43 -18
- package/docs/styles/README.md +187 -98
- package/docs/usage.md +32 -7
- package/package.json +2 -2
- package/src/components/Footer/Footer.test.tsx +482 -0
- package/src/components/Form/Form.test.tsx +1158 -0
- package/src/components/Header/Header.test.tsx +582 -0
- package/src/components/Header/Header.tsx +1 -1
- package/src/components/InactivityWarningModal/InactivityWarningModal.test.tsx +489 -0
- package/src/components/Input/Input.test.tsx +466 -0
- package/src/components/LoadingSpinner/LoadingSpinner.test.tsx +450 -0
- package/src/components/LoginForm/LoginForm.test.tsx +816 -0
- package/src/components/NavigationMenu/NavigationMenu.test.tsx +883 -0
- package/src/components/OrganisationSelector/OrganisationSelector.test.tsx +748 -0
- package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +891 -0
- package/src/components/PaceLoginPage/PaceLoginPage.test.tsx +475 -0
- package/src/components/PasswordReset/PasswordChangeForm.test.tsx +621 -0
- package/src/components/PasswordReset/PasswordResetForm.test.tsx +605 -0
- package/src/components/Select/Select.test.tsx +948 -0
- package/src/components/SuperAdminGuard.tsx +1 -1
- package/src/components/Toast/Toast.test.tsx +586 -0
- package/src/components/Tooltip/Tooltip.test.tsx +852 -0
- package/src/components/UserMenu/UserMenu.test.tsx +702 -0
- package/src/components/UserMenu/UserMenu.tsx +2 -2
- package/src/hooks/useDebounce.test.ts +375 -0
- package/src/hooks/useOrganisationPermissions.test.ts +528 -0
- package/src/hooks/useOrganisationSecurity.test.ts +734 -0
- package/src/hooks/usePermissionCache.test.ts +542 -0
- package/src/hooks/usePermissionCache.ts +1 -1
- package/src/index.ts +2 -3
- package/src/providers/UnifiedAuthProvider.tsx +2 -2
- package/src/providers/index.ts +3 -1
- package/src/rbac/__tests__/integration.test.tsx +218 -0
- package/src/rbac/api.test.ts +952 -0
- package/src/rbac/components/__tests__/NavigationGuard.test.tsx +843 -0
- package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +1007 -0
- package/src/rbac/components/__tests__/PermissionEnforcer.test.tsx +806 -0
- package/src/rbac/components/__tests__/RoleBasedRouter.test.tsx +741 -0
- package/src/rbac/hooks/index.ts +21 -0
- package/src/rbac/hooks/useCan.test.ts +461 -0
- package/src/rbac/hooks/usePermissions.test.ts +364 -0
- package/src/rbac/hooks/usePermissions.ts +567 -0
- package/src/rbac/hooks/useRBAC.simple.test.ts +90 -0
- package/src/rbac/hooks/useRBAC.test.ts +551 -0
- package/src/{hooks → rbac/hooks}/useRBAC.ts +7 -7
- package/src/rbac/index.ts +5 -10
- package/src/{providers → rbac/providers}/RBACProvider.tsx +6 -6
- package/src/rbac/providers/__tests__/RBACProvider.test.tsx +687 -0
- package/src/rbac/providers/index.ts +11 -0
- package/src/styles/core.css +0 -55
- package/src/utils/formatDate.test.ts +241 -0
- package/dist/chunk-AUE24LVR.js +0 -268
- package/dist/chunk-AUE24LVR.js.map +0 -1
- package/dist/chunk-COBPIXXQ.js +0 -379
- package/dist/chunk-COBPIXXQ.js.map +0 -1
- package/dist/chunk-OEGRKULD.js.map +0 -1
- package/dist/chunk-OYRY44Q2.js +0 -62
- package/dist/chunk-OYRY44Q2.js.map +0 -1
- package/dist/chunk-T3XIA4AJ.js.map +0 -1
- package/dist/chunk-TGDCLPP2.js.map +0 -1
- package/src/components/RBAC/PagePermissionGuard.tsx +0 -287
- package/src/components/RBAC/RBACGuard.tsx +0 -143
- package/src/components/RBAC/RBACProvider.tsx +0 -186
- package/src/components/RBAC/RoleBasedContent.tsx +0 -129
- package/src/components/RBAC/index.ts +0 -23
- package/src/rbac/hooks.ts +0 -570
- /package/dist/{DataTable-GX3XERFJ.js.map → DataTable-ZQDRE46Q.js.map} +0 -0
- /package/dist/{api-ETQ6YJ3C.js.map → api-H5A3H4IR.js.map} +0 -0
- /package/dist/{chunk-C5G2A4PO.js.map → chunk-GWSBHC4J.js.map} +0 -0
- /package/dist/{chunk-5EL3KHOQ.js.map → chunk-K6B7BLSE.js.map} +0 -0
- /package/dist/{chunk-GSNM5D6H.js.map → chunk-M4RW7PIP.js.map} +0 -0
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Form Component Tests
|
|
3
|
+
* @package @jmruthers/pace-core
|
|
4
|
+
* @module Components/Form
|
|
5
|
+
* @since 0.1.0
|
|
6
|
+
*
|
|
7
|
+
* Comprehensive test suite for the Form component and its sub-components.
|
|
8
|
+
* Tests form rendering, validation, submission, and accessibility.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
|
13
|
+
import userEvent from '@testing-library/user-event';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { Form, FormField, FormFieldset, FormErrorSummary, FormLiveRegion } from './index';
|
|
16
|
+
import { renderWithProviders } from '../../__tests__/helpers/test-utils';
|
|
17
|
+
|
|
18
|
+
// Mock the cn utility
|
|
19
|
+
vi.mock('../../utils/cn', () => ({
|
|
20
|
+
cn: (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' ')
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Mock the Label component
|
|
24
|
+
vi.mock('../Label', () => ({
|
|
25
|
+
Label: ({ children, htmlFor, ...props }: any) => (
|
|
26
|
+
<label htmlFor={htmlFor} {...props}>
|
|
27
|
+
{children}
|
|
28
|
+
</label>
|
|
29
|
+
)
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Mock the Alert component
|
|
33
|
+
vi.mock('../Alert', () => ({
|
|
34
|
+
Alert: ({ children, variant, className, ...props }: any) => (
|
|
35
|
+
<div role="alert" className={`alert alert-${variant} ${className || ''}`} {...props}>
|
|
36
|
+
{children}
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
describe('Form Component', () => {
|
|
42
|
+
const user = userEvent.setup();
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
vi.clearAllMocks();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('Rendering', () => {
|
|
49
|
+
it('renders with basic props', () => {
|
|
50
|
+
const onSubmit = vi.fn();
|
|
51
|
+
|
|
52
|
+
renderWithProviders(
|
|
53
|
+
<Form onSubmit={onSubmit}>
|
|
54
|
+
<FormField name="test" label="Test Field" />
|
|
55
|
+
</Form>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const form = document.querySelector('form');
|
|
59
|
+
expect(form).toBeInTheDocument();
|
|
60
|
+
expect(screen.getByLabelText('Test Field')).toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('renders with custom className', () => {
|
|
64
|
+
const onSubmit = vi.fn();
|
|
65
|
+
|
|
66
|
+
renderWithProviders(
|
|
67
|
+
<Form onSubmit={onSubmit} className="custom-form">
|
|
68
|
+
<FormField name="test" label="Test Field" />
|
|
69
|
+
</Form>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const form = document.querySelector('form');
|
|
73
|
+
expect(form).toHaveClass('custom-form');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('renders with children function', () => {
|
|
77
|
+
const onSubmit = vi.fn();
|
|
78
|
+
const childrenFn = vi.fn().mockReturnValue(
|
|
79
|
+
<FormField name="test" label="Test Field" />
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
renderWithProviders(
|
|
83
|
+
<Form onSubmit={onSubmit}>
|
|
84
|
+
{childrenFn}
|
|
85
|
+
</Form>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(childrenFn).toHaveBeenCalledWith(
|
|
89
|
+
expect.objectContaining({
|
|
90
|
+
control: expect.any(Object),
|
|
91
|
+
formState: expect.any(Object),
|
|
92
|
+
handleSubmit: expect.any(Function)
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
expect(screen.getByLabelText('Test Field')).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('renders with default values', () => {
|
|
99
|
+
const onSubmit = vi.fn();
|
|
100
|
+
const defaultValues = { name: 'John', email: 'john@example.com' };
|
|
101
|
+
|
|
102
|
+
renderWithProviders(
|
|
103
|
+
<Form onSubmit={onSubmit} defaultValues={defaultValues}>
|
|
104
|
+
<FormField name="name" label="Name" />
|
|
105
|
+
<FormField name="email" label="Email" />
|
|
106
|
+
</Form>
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(screen.getByDisplayValue('John')).toBeInTheDocument();
|
|
110
|
+
expect(screen.getByDisplayValue('john@example.com')).toBeInTheDocument();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('Form Submission', () => {
|
|
115
|
+
it('calls onSubmit when form is submitted successfully', async () => {
|
|
116
|
+
const onSubmit = vi.fn();
|
|
117
|
+
const schema = z.object({
|
|
118
|
+
name: z.string().min(1, 'Name is required')
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
renderWithProviders(
|
|
122
|
+
<Form schema={schema} onSubmit={onSubmit}>
|
|
123
|
+
<FormField name="name" label="Name" />
|
|
124
|
+
<button type="submit">Submit</button>
|
|
125
|
+
</Form>
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
await user.type(screen.getByLabelText('Name'), 'John Doe');
|
|
129
|
+
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
|
130
|
+
|
|
131
|
+
expect(onSubmit).toHaveBeenCalledWith({ name: 'John Doe' }, expect.any(Object));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('calls onError when form has validation errors', async () => {
|
|
135
|
+
const onSubmit = vi.fn();
|
|
136
|
+
const onError = vi.fn();
|
|
137
|
+
const schema = z.object({
|
|
138
|
+
name: z.string().min(1, 'Name is required')
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
renderWithProviders(
|
|
142
|
+
<Form schema={schema} onSubmit={onSubmit} onError={onError}>
|
|
143
|
+
<FormField name="name" label="Name" />
|
|
144
|
+
<button type="submit">Submit</button>
|
|
145
|
+
</Form>
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
|
149
|
+
|
|
150
|
+
expect(onError).toHaveBeenCalledWith(
|
|
151
|
+
expect.objectContaining({
|
|
152
|
+
name: expect.objectContaining({
|
|
153
|
+
message: 'Required'
|
|
154
|
+
})
|
|
155
|
+
}),
|
|
156
|
+
expect.any(Object)
|
|
157
|
+
);
|
|
158
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('handles form submission without schema', async () => {
|
|
162
|
+
const onSubmit = vi.fn();
|
|
163
|
+
|
|
164
|
+
renderWithProviders(
|
|
165
|
+
<Form onSubmit={onSubmit}>
|
|
166
|
+
<FormField name="name" label="Name" />
|
|
167
|
+
<button type="submit">Submit</button>
|
|
168
|
+
</Form>
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
await user.type(screen.getByLabelText('Name'), 'John Doe');
|
|
172
|
+
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
|
173
|
+
|
|
174
|
+
expect(onSubmit).toHaveBeenCalledWith({ name: 'John Doe' }, expect.any(Object));
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('Validation Modes', () => {
|
|
179
|
+
it('validates on submit by default', async () => {
|
|
180
|
+
const onSubmit = vi.fn();
|
|
181
|
+
const schema = z.object({
|
|
182
|
+
name: z.string().min(1, 'Name is required')
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
renderWithProviders(
|
|
186
|
+
<Form schema={schema} onSubmit={onSubmit}>
|
|
187
|
+
<FormField name="name" label="Name" />
|
|
188
|
+
<button type="submit">Submit</button>
|
|
189
|
+
</Form>
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Type and clear to trigger validation
|
|
193
|
+
await user.type(screen.getByLabelText('Name'), 'John');
|
|
194
|
+
await user.clear(screen.getByLabelText('Name'));
|
|
195
|
+
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
|
196
|
+
|
|
197
|
+
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('validates on change when mode is onChange', async () => {
|
|
201
|
+
const onSubmit = vi.fn();
|
|
202
|
+
const schema = z.object({
|
|
203
|
+
name: z.string().min(1, 'Name is required')
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
renderWithProviders(
|
|
207
|
+
<Form schema={schema} onSubmit={onSubmit} mode="onChange">
|
|
208
|
+
<FormField name="name" label="Name" />
|
|
209
|
+
<button type="submit">Submit</button>
|
|
210
|
+
</Form>
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Type and clear to trigger validation
|
|
214
|
+
await user.type(screen.getByLabelText('Name'), 'John');
|
|
215
|
+
await user.clear(screen.getByLabelText('Name'));
|
|
216
|
+
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('validates on blur when mode is onBlur', async () => {
|
|
223
|
+
const onSubmit = vi.fn();
|
|
224
|
+
const schema = z.object({
|
|
225
|
+
name: z.string().min(1, 'Name is required')
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
renderWithProviders(
|
|
229
|
+
<Form schema={schema} onSubmit={onSubmit} mode="onBlur">
|
|
230
|
+
<FormField name="name" label="Name" />
|
|
231
|
+
<button type="submit">Submit</button>
|
|
232
|
+
</Form>
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Focus and blur to trigger validation
|
|
236
|
+
await user.click(screen.getByLabelText('Name'));
|
|
237
|
+
await user.tab();
|
|
238
|
+
|
|
239
|
+
await waitFor(() => {
|
|
240
|
+
expect(screen.getByText('Required')).toBeInTheDocument();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('Accessibility', () => {
|
|
246
|
+
it('has proper form structure', () => {
|
|
247
|
+
const onSubmit = vi.fn();
|
|
248
|
+
|
|
249
|
+
renderWithProviders(
|
|
250
|
+
<Form onSubmit={onSubmit}>
|
|
251
|
+
<FormField name="name" label="Name" />
|
|
252
|
+
<button type="submit">Submit</button>
|
|
253
|
+
</Form>
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const form = document.querySelector('form');
|
|
257
|
+
expect(form).toBeInTheDocument();
|
|
258
|
+
expect(form).toHaveAttribute('class', 'space-y-4');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('provides form context to children', () => {
|
|
262
|
+
const onSubmit = vi.fn();
|
|
263
|
+
const childrenFn = vi.fn().mockReturnValue(
|
|
264
|
+
<FormField name="test" label="Test Field" />
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
renderWithProviders(
|
|
268
|
+
<Form onSubmit={onSubmit}>
|
|
269
|
+
{childrenFn}
|
|
270
|
+
</Form>
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(childrenFn).toHaveBeenCalledWith(
|
|
274
|
+
expect.objectContaining({
|
|
275
|
+
control: expect.any(Object),
|
|
276
|
+
formState: expect.any(Object),
|
|
277
|
+
handleSubmit: expect.any(Function),
|
|
278
|
+
register: expect.any(Function),
|
|
279
|
+
watch: expect.any(Function),
|
|
280
|
+
setValue: expect.any(Function),
|
|
281
|
+
getValues: expect.any(Function),
|
|
282
|
+
reset: expect.any(Function)
|
|
283
|
+
})
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('Edge Cases', () => {
|
|
289
|
+
it('handles empty children', () => {
|
|
290
|
+
const onSubmit = vi.fn();
|
|
291
|
+
|
|
292
|
+
renderWithProviders(
|
|
293
|
+
<Form onSubmit={onSubmit}>
|
|
294
|
+
{null}
|
|
295
|
+
</Form>
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const form = document.querySelector('form');
|
|
299
|
+
expect(form).toBeInTheDocument();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('handles undefined schema', () => {
|
|
303
|
+
const onSubmit = vi.fn();
|
|
304
|
+
|
|
305
|
+
renderWithProviders(
|
|
306
|
+
<Form onSubmit={onSubmit} schema={undefined}>
|
|
307
|
+
<FormField name="test" label="Test Field" />
|
|
308
|
+
</Form>
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const form = document.querySelector('form');
|
|
312
|
+
expect(form).toBeInTheDocument();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('handles complex nested data', async () => {
|
|
316
|
+
const onSubmit = vi.fn();
|
|
317
|
+
const schema = z.object({
|
|
318
|
+
user: z.object({
|
|
319
|
+
name: z.string(),
|
|
320
|
+
email: z.string().email()
|
|
321
|
+
}),
|
|
322
|
+
preferences: z.object({
|
|
323
|
+
theme: z.string(),
|
|
324
|
+
notifications: z.boolean().optional()
|
|
325
|
+
})
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
renderWithProviders(
|
|
329
|
+
<Form schema={schema} onSubmit={onSubmit}>
|
|
330
|
+
<FormField name="user.name" label="Name" />
|
|
331
|
+
<FormField name="user.email" label="Email" />
|
|
332
|
+
<FormField name="preferences.theme" label="Theme" />
|
|
333
|
+
<button type="submit">Submit</button>
|
|
334
|
+
</Form>
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
await user.type(screen.getByLabelText('Name'), 'John');
|
|
338
|
+
await user.type(screen.getByLabelText('Email'), 'john@example.com');
|
|
339
|
+
await user.type(screen.getByLabelText('Theme'), 'dark');
|
|
340
|
+
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
|
341
|
+
|
|
342
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
343
|
+
user: {
|
|
344
|
+
name: 'John',
|
|
345
|
+
email: 'john@example.com'
|
|
346
|
+
},
|
|
347
|
+
preferences: {
|
|
348
|
+
theme: 'dark',
|
|
349
|
+
notifications: undefined
|
|
350
|
+
}
|
|
351
|
+
}, expect.any(Object));
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe('FormField Component', () => {
|
|
357
|
+
const user = userEvent.setup();
|
|
358
|
+
|
|
359
|
+
beforeEach(() => {
|
|
360
|
+
vi.clearAllMocks();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe('Rendering', () => {
|
|
364
|
+
it('renders with basic props', () => {
|
|
365
|
+
const onSubmit = vi.fn();
|
|
366
|
+
|
|
367
|
+
renderWithProviders(
|
|
368
|
+
<Form onSubmit={onSubmit}>
|
|
369
|
+
<FormField name="test" label="Test Field" />
|
|
370
|
+
</Form>
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const input = screen.getByLabelText('Test Field');
|
|
374
|
+
expect(input).toBeInTheDocument();
|
|
375
|
+
expect(input).toHaveAttribute('name', 'test');
|
|
376
|
+
expect(input).toHaveAttribute('id', 'test');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('renders with custom type', () => {
|
|
380
|
+
const onSubmit = vi.fn();
|
|
381
|
+
|
|
382
|
+
renderWithProviders(
|
|
383
|
+
<Form onSubmit={onSubmit}>
|
|
384
|
+
<FormField name="email" label="Email" type="email" />
|
|
385
|
+
</Form>
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const input = screen.getByLabelText('Email');
|
|
389
|
+
expect(input).toHaveAttribute('type', 'email');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('renders with placeholder', () => {
|
|
393
|
+
const onSubmit = vi.fn();
|
|
394
|
+
|
|
395
|
+
renderWithProviders(
|
|
396
|
+
<Form onSubmit={onSubmit}>
|
|
397
|
+
<FormField name="test" label="Test Field" placeholder="Enter text" />
|
|
398
|
+
</Form>
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
const input = screen.getByLabelText('Test Field');
|
|
402
|
+
expect(input).toHaveAttribute('placeholder', 'Enter text');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('renders with custom className', () => {
|
|
406
|
+
const onSubmit = vi.fn();
|
|
407
|
+
|
|
408
|
+
renderWithProviders(
|
|
409
|
+
<Form onSubmit={onSubmit}>
|
|
410
|
+
<FormField name="test" label="Test Field" className="custom-field" />
|
|
411
|
+
</Form>
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const fieldContainer = screen.getByLabelText('Test Field').closest('div');
|
|
415
|
+
expect(fieldContainer).toHaveClass('custom-field');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('renders with test ID', () => {
|
|
419
|
+
const onSubmit = vi.fn();
|
|
420
|
+
|
|
421
|
+
renderWithProviders(
|
|
422
|
+
<Form onSubmit={onSubmit}>
|
|
423
|
+
<FormField name="test" label="Test Field" data-testid="test-field" />
|
|
424
|
+
</Form>
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const input = screen.getByTestId('test-field');
|
|
428
|
+
expect(input).toBeInTheDocument();
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe('Validation', () => {
|
|
433
|
+
it('shows required indicator when validation.required is true', () => {
|
|
434
|
+
const onSubmit = vi.fn();
|
|
435
|
+
|
|
436
|
+
renderWithProviders(
|
|
437
|
+
<Form onSubmit={onSubmit}>
|
|
438
|
+
<FormField
|
|
439
|
+
name="test"
|
|
440
|
+
label="Test Field"
|
|
441
|
+
validation={{ required: true }}
|
|
442
|
+
/>
|
|
443
|
+
</Form>
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
expect(screen.getByText('*')).toBeInTheDocument();
|
|
447
|
+
expect(screen.getByText('*')).toHaveAttribute('aria-label', 'required');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('shows error message when validation fails', async () => {
|
|
451
|
+
const onSubmit = vi.fn();
|
|
452
|
+
const schema = z.object({
|
|
453
|
+
test: z.string().min(1, 'Field is required')
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
renderWithProviders(
|
|
457
|
+
<Form schema={schema} onSubmit={onSubmit}>
|
|
458
|
+
<FormField name="test" label="Test Field" />
|
|
459
|
+
<button type="submit">Submit</button>
|
|
460
|
+
</Form>
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
|
464
|
+
|
|
465
|
+
expect(screen.getByText('Required')).toBeInTheDocument();
|
|
466
|
+
expect(screen.getByText('Required')).toHaveAttribute('role', 'alert');
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('applies error styling when field has error', async () => {
|
|
470
|
+
const onSubmit = vi.fn();
|
|
471
|
+
const schema = z.object({
|
|
472
|
+
test: z.string().min(1, 'Field is required')
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
renderWithProviders(
|
|
476
|
+
<Form schema={schema} onSubmit={onSubmit}>
|
|
477
|
+
<FormField name="test" label="Test Field" />
|
|
478
|
+
<button type="submit">Submit</button>
|
|
479
|
+
</Form>
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
|
483
|
+
|
|
484
|
+
const input = screen.getByLabelText('Test Field');
|
|
485
|
+
expect(input).toHaveClass('border-destructive', 'focus-visible:ring-destructive');
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('validates with custom validation rules', async () => {
|
|
489
|
+
const onSubmit = vi.fn();
|
|
490
|
+
|
|
491
|
+
renderWithProviders(
|
|
492
|
+
<Form onSubmit={onSubmit}>
|
|
493
|
+
<FormField
|
|
494
|
+
name="test"
|
|
495
|
+
label="Test Field"
|
|
496
|
+
validation={{
|
|
497
|
+
required: true,
|
|
498
|
+
minLength: {
|
|
499
|
+
value: 3,
|
|
500
|
+
message: 'Must be at least 3 characters'
|
|
501
|
+
}
|
|
502
|
+
}}
|
|
503
|
+
/>
|
|
504
|
+
<button type="submit">Submit</button>
|
|
505
|
+
</Form>
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
const input = screen.getByRole('textbox', { name: /test field/i });
|
|
509
|
+
await user.type(input, 'ab');
|
|
510
|
+
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
|
511
|
+
|
|
512
|
+
expect(screen.getByText('Must be at least 3 characters')).toBeInTheDocument();
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
describe('Custom Render Function', () => {
|
|
517
|
+
it('renders custom input when render function is provided', () => {
|
|
518
|
+
const onSubmit = vi.fn();
|
|
519
|
+
const renderFn = vi.fn().mockReturnValue(
|
|
520
|
+
<input data-testid="custom-input" />
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
renderWithProviders(
|
|
524
|
+
<Form onSubmit={onSubmit}>
|
|
525
|
+
<FormField name="test" label="Test Field" render={renderFn} />
|
|
526
|
+
</Form>
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
expect(renderFn).toHaveBeenCalledWith({
|
|
530
|
+
field: expect.objectContaining({
|
|
531
|
+
name: 'test',
|
|
532
|
+
value: undefined,
|
|
533
|
+
onChange: expect.any(Function),
|
|
534
|
+
onBlur: expect.any(Function),
|
|
535
|
+
ref: expect.any(Function)
|
|
536
|
+
})
|
|
537
|
+
});
|
|
538
|
+
expect(screen.getByTestId('custom-input')).toBeInTheDocument();
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
describe('Accessibility', () => {
|
|
543
|
+
it('associates label with input', () => {
|
|
544
|
+
const onSubmit = vi.fn();
|
|
545
|
+
|
|
546
|
+
renderWithProviders(
|
|
547
|
+
<Form onSubmit={onSubmit}>
|
|
548
|
+
<FormField name="test" label="Test Field" />
|
|
549
|
+
</Form>
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
const input = screen.getByLabelText('Test Field');
|
|
553
|
+
expect(input).toHaveAttribute('id', 'test');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('shows error message with proper role', async () => {
|
|
557
|
+
const onSubmit = vi.fn();
|
|
558
|
+
const schema = z.object({
|
|
559
|
+
test: z.string().min(1, 'Field is required')
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
renderWithProviders(
|
|
563
|
+
<Form schema={schema} onSubmit={onSubmit}>
|
|
564
|
+
<FormField name="test" label="Test Field" />
|
|
565
|
+
<button type="submit">Submit</button>
|
|
566
|
+
</Form>
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
|
570
|
+
|
|
571
|
+
const errorMessage = screen.getByText('Required');
|
|
572
|
+
expect(errorMessage).toHaveAttribute('role', 'alert');
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
describe('FormFieldset Component', () => {
|
|
578
|
+
beforeEach(() => {
|
|
579
|
+
vi.clearAllMocks();
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
describe('Rendering', () => {
|
|
583
|
+
it('renders with legend', () => {
|
|
584
|
+
renderWithProviders(
|
|
585
|
+
<FormFieldset legend="Personal Information">
|
|
586
|
+
<div>Field content</div>
|
|
587
|
+
</FormFieldset>
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
expect(screen.getByText('Personal Information')).toBeInTheDocument();
|
|
591
|
+
expect(screen.getByRole('group')).toBeInTheDocument();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('renders with description', () => {
|
|
595
|
+
renderWithProviders(
|
|
596
|
+
<FormFieldset
|
|
597
|
+
legend="Personal Information"
|
|
598
|
+
description="Please provide your personal details"
|
|
599
|
+
>
|
|
600
|
+
<div>Field content</div>
|
|
601
|
+
</FormFieldset>
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
expect(screen.getByText('Please provide your personal details')).toBeInTheDocument();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('renders with required indicator', () => {
|
|
608
|
+
renderWithProviders(
|
|
609
|
+
<FormFieldset legend="Personal Information" required>
|
|
610
|
+
<div>Field content</div>
|
|
611
|
+
</FormFieldset>
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
expect(screen.getByText('*')).toBeInTheDocument();
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('renders with custom className', () => {
|
|
618
|
+
renderWithProviders(
|
|
619
|
+
<FormFieldset
|
|
620
|
+
legend="Personal Information"
|
|
621
|
+
className="custom-fieldset"
|
|
622
|
+
>
|
|
623
|
+
<div>Field content</div>
|
|
624
|
+
</FormFieldset>
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
const fieldset = screen.getByRole('group');
|
|
628
|
+
expect(fieldset).toHaveClass('custom-fieldset');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('forwards ref correctly', () => {
|
|
632
|
+
const ref = { current: null };
|
|
633
|
+
|
|
634
|
+
renderWithProviders(
|
|
635
|
+
<FormFieldset legend="Personal Information" ref={ref}>
|
|
636
|
+
<div>Field content</div>
|
|
637
|
+
</FormFieldset>
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
expect(ref.current).toBeInstanceOf(HTMLFieldSetElement);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
describe('Accessibility', () => {
|
|
645
|
+
it('has proper ARIA attributes', () => {
|
|
646
|
+
renderWithProviders(
|
|
647
|
+
<FormFieldset
|
|
648
|
+
legend="Personal Information"
|
|
649
|
+
description="Please provide your personal details"
|
|
650
|
+
>
|
|
651
|
+
<div>Field content</div>
|
|
652
|
+
</FormFieldset>
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
const fieldset = screen.getByRole('group');
|
|
656
|
+
const description = screen.getByText('Please provide your personal details');
|
|
657
|
+
|
|
658
|
+
expect(fieldset).toHaveAttribute('aria-describedby', description.id);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('has proper semantic structure', () => {
|
|
662
|
+
renderWithProviders(
|
|
663
|
+
<FormFieldset legend="Personal Information">
|
|
664
|
+
<div>Field content</div>
|
|
665
|
+
</FormFieldset>
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
const fieldset = screen.getByRole('group');
|
|
669
|
+
const legend = screen.getByText('Personal Information');
|
|
670
|
+
|
|
671
|
+
expect(fieldset).toBeInTheDocument();
|
|
672
|
+
expect(legend).toBeInTheDocument();
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
describe('FormErrorSummary Component', () => {
|
|
678
|
+
beforeEach(() => {
|
|
679
|
+
vi.clearAllMocks();
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
describe('Rendering', () => {
|
|
683
|
+
it('renders nothing when no errors', () => {
|
|
684
|
+
renderWithProviders(
|
|
685
|
+
<FormErrorSummary errors={{}} />
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it('renders with default title', () => {
|
|
692
|
+
const errors = {
|
|
693
|
+
name: { message: 'Name is required' },
|
|
694
|
+
email: { message: 'Email is invalid' }
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
renderWithProviders(
|
|
698
|
+
<FormErrorSummary errors={errors} />
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
expect(screen.getByText('Please fix the following errors:')).toBeInTheDocument();
|
|
702
|
+
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
|
703
|
+
expect(screen.getByText('Email is invalid')).toBeInTheDocument();
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('renders with custom title', () => {
|
|
707
|
+
const errors = {
|
|
708
|
+
name: { message: 'Name is required' }
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
renderWithProviders(
|
|
712
|
+
<FormErrorSummary
|
|
713
|
+
errors={errors}
|
|
714
|
+
title="Please correct these issues:"
|
|
715
|
+
/>
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
expect(screen.getByText('Please correct these issues:')).toBeInTheDocument();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it('renders with field names when showFieldNames is true', () => {
|
|
722
|
+
const errors = {
|
|
723
|
+
name: { message: 'Name is required' },
|
|
724
|
+
email: { message: 'Email is invalid' }
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
renderWithProviders(
|
|
728
|
+
<FormErrorSummary
|
|
729
|
+
errors={errors}
|
|
730
|
+
showFieldNames={true}
|
|
731
|
+
/>
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
expect(screen.getByText('name: Name is required')).toBeInTheDocument();
|
|
735
|
+
expect(screen.getByText('email: Email is invalid')).toBeInTheDocument();
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('filters out empty errors', () => {
|
|
739
|
+
const errors = {
|
|
740
|
+
name: { message: 'Name is required' },
|
|
741
|
+
email: null,
|
|
742
|
+
phone: undefined,
|
|
743
|
+
address: ''
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
renderWithProviders(
|
|
747
|
+
<FormErrorSummary errors={errors} />
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
|
751
|
+
expect(screen.queryByText('email:')).not.toBeInTheDocument();
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
describe('Error Message Handling', () => {
|
|
756
|
+
it('handles string error messages', () => {
|
|
757
|
+
const errors = {
|
|
758
|
+
name: 'Name is required'
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
renderWithProviders(
|
|
762
|
+
<FormErrorSummary errors={errors} />
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('handles object error messages', () => {
|
|
769
|
+
const errors = {
|
|
770
|
+
name: { message: 'Name is required' }
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
renderWithProviders(
|
|
774
|
+
<FormErrorSummary errors={errors} />
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
describe('FormLiveRegion Component', () => {
|
|
783
|
+
beforeEach(() => {
|
|
784
|
+
vi.clearAllMocks();
|
|
785
|
+
vi.useFakeTimers();
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
afterEach(() => {
|
|
789
|
+
vi.useRealTimers();
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
describe('Rendering', () => {
|
|
793
|
+
it('renders nothing when no message', () => {
|
|
794
|
+
const mockForm = {
|
|
795
|
+
formState: {
|
|
796
|
+
isSubmitting: false,
|
|
797
|
+
isSubmitSuccessful: false,
|
|
798
|
+
errors: {},
|
|
799
|
+
isValid: false,
|
|
800
|
+
isDirty: false
|
|
801
|
+
}
|
|
802
|
+
} as any;
|
|
803
|
+
|
|
804
|
+
renderWithProviders(
|
|
805
|
+
<FormLiveRegion form={mockForm} />
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it('renders with default politeness', () => {
|
|
812
|
+
const mockForm = {
|
|
813
|
+
formState: {
|
|
814
|
+
isSubmitting: true,
|
|
815
|
+
isSubmitSuccessful: false,
|
|
816
|
+
errors: {},
|
|
817
|
+
isValid: false,
|
|
818
|
+
isDirty: false
|
|
819
|
+
}
|
|
820
|
+
} as any;
|
|
821
|
+
|
|
822
|
+
renderWithProviders(
|
|
823
|
+
<FormLiveRegion form={mockForm} />
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
const liveRegion = screen.getByRole('status');
|
|
827
|
+
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('renders with custom politeness', () => {
|
|
831
|
+
const mockForm = {
|
|
832
|
+
formState: {
|
|
833
|
+
isSubmitting: true,
|
|
834
|
+
isSubmitSuccessful: false,
|
|
835
|
+
errors: {},
|
|
836
|
+
isValid: false,
|
|
837
|
+
isDirty: false
|
|
838
|
+
}
|
|
839
|
+
} as any;
|
|
840
|
+
|
|
841
|
+
renderWithProviders(
|
|
842
|
+
<FormLiveRegion form={mockForm} politeness="assertive" />
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
const liveRegion = screen.getByRole('status');
|
|
846
|
+
expect(liveRegion).toHaveAttribute('aria-live', 'assertive');
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
describe('Form State Announcements', () => {
|
|
851
|
+
it('announces submitting state', () => {
|
|
852
|
+
const mockForm = {
|
|
853
|
+
formState: {
|
|
854
|
+
isSubmitting: true,
|
|
855
|
+
isSubmitSuccessful: false,
|
|
856
|
+
errors: {},
|
|
857
|
+
isValid: false,
|
|
858
|
+
isDirty: false
|
|
859
|
+
}
|
|
860
|
+
} as any;
|
|
861
|
+
|
|
862
|
+
renderWithProviders(
|
|
863
|
+
<FormLiveRegion form={mockForm} />
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
expect(screen.getByText('Submitting form...')).toBeInTheDocument();
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('announces successful submission', async () => {
|
|
870
|
+
const mockForm = {
|
|
871
|
+
formState: {
|
|
872
|
+
isSubmitting: false,
|
|
873
|
+
isSubmitSuccessful: true,
|
|
874
|
+
errors: {},
|
|
875
|
+
isValid: false,
|
|
876
|
+
isDirty: false
|
|
877
|
+
}
|
|
878
|
+
} as any;
|
|
879
|
+
|
|
880
|
+
renderWithProviders(
|
|
881
|
+
<FormLiveRegion form={mockForm} />
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
// Advance timers to trigger the timeout
|
|
885
|
+
await act(async () => {
|
|
886
|
+
vi.advanceTimersByTime(500);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// The message should be set after the timeout
|
|
890
|
+
expect(screen.getByRole('status')).toHaveTextContent('Form submitted successfully');
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
it('announces custom success message', async () => {
|
|
894
|
+
const mockForm = {
|
|
895
|
+
formState: {
|
|
896
|
+
isSubmitting: false,
|
|
897
|
+
isSubmitSuccessful: true,
|
|
898
|
+
errors: {},
|
|
899
|
+
isValid: false,
|
|
900
|
+
isDirty: false
|
|
901
|
+
}
|
|
902
|
+
} as any;
|
|
903
|
+
|
|
904
|
+
renderWithProviders(
|
|
905
|
+
<FormLiveRegion
|
|
906
|
+
form={mockForm}
|
|
907
|
+
successMessage="Your data was saved successfully!"
|
|
908
|
+
/>
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
await act(async () => {
|
|
912
|
+
vi.advanceTimersByTime(500);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
expect(screen.getByRole('status')).toHaveTextContent('Your data was saved successfully!');
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it('announces form errors', async () => {
|
|
919
|
+
const mockForm = {
|
|
920
|
+
formState: {
|
|
921
|
+
isSubmitting: false,
|
|
922
|
+
isSubmitSuccessful: false,
|
|
923
|
+
errors: {
|
|
924
|
+
name: { message: 'Name is required' },
|
|
925
|
+
email: { message: 'Email is invalid' }
|
|
926
|
+
},
|
|
927
|
+
isValid: false,
|
|
928
|
+
isDirty: false,
|
|
929
|
+
touchedFields: {}
|
|
930
|
+
}
|
|
931
|
+
} as any;
|
|
932
|
+
|
|
933
|
+
renderWithProviders(
|
|
934
|
+
<FormLiveRegion form={mockForm} />
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
// Error messages are set immediately without a timeout
|
|
938
|
+
expect(screen.getByRole('status')).toHaveTextContent('Form has 2 errors in: name, email');
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it('announces single error', async () => {
|
|
942
|
+
const mockForm = {
|
|
943
|
+
formState: {
|
|
944
|
+
isSubmitting: false,
|
|
945
|
+
isSubmitSuccessful: false,
|
|
946
|
+
errors: {
|
|
947
|
+
name: { message: 'Name is required' }
|
|
948
|
+
},
|
|
949
|
+
isValid: false,
|
|
950
|
+
isDirty: false,
|
|
951
|
+
touchedFields: {}
|
|
952
|
+
}
|
|
953
|
+
} as any;
|
|
954
|
+
|
|
955
|
+
renderWithProviders(
|
|
956
|
+
<FormLiveRegion form={mockForm} />
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
// Error messages are set immediately without a timeout
|
|
960
|
+
expect(screen.getByRole('status')).toHaveTextContent('Form has 1 error in: name');
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it('announces valid form', () => {
|
|
964
|
+
const mockForm = {
|
|
965
|
+
formState: {
|
|
966
|
+
isSubmitting: false,
|
|
967
|
+
isSubmitSuccessful: false,
|
|
968
|
+
errors: {},
|
|
969
|
+
isValid: true,
|
|
970
|
+
isDirty: true
|
|
971
|
+
}
|
|
972
|
+
} as any;
|
|
973
|
+
|
|
974
|
+
renderWithProviders(
|
|
975
|
+
<FormLiveRegion form={mockForm} />
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
expect(screen.getByRole('status')).toHaveTextContent('Form is valid');
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
describe('Field-Level Announcements', () => {
|
|
983
|
+
it('announces field errors when enabled', async () => {
|
|
984
|
+
const mockForm = {
|
|
985
|
+
formState: {
|
|
986
|
+
isSubmitting: false,
|
|
987
|
+
isSubmitSuccessful: false,
|
|
988
|
+
errors: {
|
|
989
|
+
name: { message: 'Name is required' }
|
|
990
|
+
},
|
|
991
|
+
touchedFields: { name: true },
|
|
992
|
+
isValid: false,
|
|
993
|
+
isDirty: false
|
|
994
|
+
}
|
|
995
|
+
} as any;
|
|
996
|
+
|
|
997
|
+
renderWithProviders(
|
|
998
|
+
<FormLiveRegion form={mockForm} enableFieldAnnouncements={true} />
|
|
999
|
+
);
|
|
1000
|
+
|
|
1001
|
+
await act(async () => {
|
|
1002
|
+
vi.advanceTimersByTime(500);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
expect(screen.getByRole('status')).toHaveTextContent('name: Name is required');
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it('does not announce field errors when disabled', () => {
|
|
1009
|
+
const mockForm = {
|
|
1010
|
+
formState: {
|
|
1011
|
+
isSubmitting: false,
|
|
1012
|
+
isSubmitSuccessful: false,
|
|
1013
|
+
errors: {
|
|
1014
|
+
name: { message: 'Name is required' }
|
|
1015
|
+
},
|
|
1016
|
+
touchedFields: { name: true },
|
|
1017
|
+
isValid: false,
|
|
1018
|
+
isDirty: false
|
|
1019
|
+
}
|
|
1020
|
+
} as any;
|
|
1021
|
+
|
|
1022
|
+
renderWithProviders(
|
|
1023
|
+
<FormLiveRegion form={mockForm} enableFieldAnnouncements={false} />
|
|
1024
|
+
);
|
|
1025
|
+
|
|
1026
|
+
vi.advanceTimersByTime(500);
|
|
1027
|
+
|
|
1028
|
+
expect(screen.queryByText('name: Name is required')).not.toBeInTheDocument();
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
describe('Accessibility', () => {
|
|
1033
|
+
it('has proper ARIA attributes', () => {
|
|
1034
|
+
const mockForm = {
|
|
1035
|
+
formState: {
|
|
1036
|
+
isSubmitting: true,
|
|
1037
|
+
isSubmitSuccessful: false,
|
|
1038
|
+
errors: {},
|
|
1039
|
+
isValid: false,
|
|
1040
|
+
isDirty: false
|
|
1041
|
+
}
|
|
1042
|
+
} as any;
|
|
1043
|
+
|
|
1044
|
+
renderWithProviders(
|
|
1045
|
+
<FormLiveRegion form={mockForm} />
|
|
1046
|
+
);
|
|
1047
|
+
|
|
1048
|
+
const liveRegion = screen.getByRole('status');
|
|
1049
|
+
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
|
|
1050
|
+
expect(liveRegion).toHaveAttribute('aria-atomic', 'true');
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it('is visually hidden', () => {
|
|
1054
|
+
const mockForm = {
|
|
1055
|
+
formState: {
|
|
1056
|
+
isSubmitting: true,
|
|
1057
|
+
isSubmitSuccessful: false,
|
|
1058
|
+
errors: {},
|
|
1059
|
+
isValid: false,
|
|
1060
|
+
isDirty: false
|
|
1061
|
+
}
|
|
1062
|
+
} as any;
|
|
1063
|
+
|
|
1064
|
+
renderWithProviders(
|
|
1065
|
+
<FormLiveRegion form={mockForm} />
|
|
1066
|
+
);
|
|
1067
|
+
|
|
1068
|
+
const liveRegion = screen.getByRole('status');
|
|
1069
|
+
expect(liveRegion).toHaveClass('sr-only');
|
|
1070
|
+
expect(liveRegion.getAttribute('style')).toContain('position: absolute');
|
|
1071
|
+
expect(liveRegion.getAttribute('style')).toContain('left: -10000px');
|
|
1072
|
+
expect(liveRegion.getAttribute('style')).toContain('width: 1px');
|
|
1073
|
+
expect(liveRegion.getAttribute('style')).toContain('height: 1px');
|
|
1074
|
+
expect(liveRegion.getAttribute('style')).toContain('overflow: hidden');
|
|
1075
|
+
});
|
|
1076
|
+
});
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
describe('Integration', () => {
|
|
1080
|
+
const user = userEvent.setup();
|
|
1081
|
+
|
|
1082
|
+
beforeEach(() => {
|
|
1083
|
+
vi.clearAllMocks();
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it('works with complete form setup', async () => {
|
|
1087
|
+
const onSubmit = vi.fn();
|
|
1088
|
+
const onError = vi.fn();
|
|
1089
|
+
const schema = z.object({
|
|
1090
|
+
name: z.string().min(1, 'Name is required'),
|
|
1091
|
+
email: z.string().email('Invalid email'),
|
|
1092
|
+
age: z.coerce.number().min(18, 'Must be at least 18')
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
renderWithProviders(
|
|
1096
|
+
<Form
|
|
1097
|
+
schema={schema}
|
|
1098
|
+
onSubmit={onSubmit}
|
|
1099
|
+
onError={onError}
|
|
1100
|
+
defaultValues={{ name: '', email: '', age: 0 }}
|
|
1101
|
+
>
|
|
1102
|
+
<FormFieldset legend="Personal Information">
|
|
1103
|
+
<FormField name="name" label="Name" />
|
|
1104
|
+
<FormField name="email" label="Email" type="email" />
|
|
1105
|
+
<FormField name="age" label="Age" type="number" />
|
|
1106
|
+
</FormFieldset>
|
|
1107
|
+
<FormErrorSummary />
|
|
1108
|
+
<button type="submit">Submit</button>
|
|
1109
|
+
</Form>
|
|
1110
|
+
);
|
|
1111
|
+
|
|
1112
|
+
// Fill out form
|
|
1113
|
+
await user.type(screen.getByLabelText('Name'), 'John Doe');
|
|
1114
|
+
await user.type(screen.getByLabelText('Email'), 'john@example.com');
|
|
1115
|
+
await user.type(screen.getByLabelText('Age'), '25');
|
|
1116
|
+
|
|
1117
|
+
// Submit form
|
|
1118
|
+
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
|
1119
|
+
|
|
1120
|
+
// Wait for form submission
|
|
1121
|
+
await waitFor(() => {
|
|
1122
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
1123
|
+
name: 'John Doe',
|
|
1124
|
+
email: 'john@example.com',
|
|
1125
|
+
age: 25
|
|
1126
|
+
}, expect.any(Object));
|
|
1127
|
+
});
|
|
1128
|
+
expect(onError).not.toHaveBeenCalled();
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it('handles form errors with error summary', async () => {
|
|
1132
|
+
const onSubmit = vi.fn();
|
|
1133
|
+
const onError = vi.fn();
|
|
1134
|
+
const schema = z.object({
|
|
1135
|
+
name: z.string().min(1, 'Name is required'),
|
|
1136
|
+
email: z.string().email('Invalid email')
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
renderWithProviders(
|
|
1140
|
+
<Form
|
|
1141
|
+
schema={schema}
|
|
1142
|
+
onSubmit={onSubmit}
|
|
1143
|
+
onError={onError}
|
|
1144
|
+
>
|
|
1145
|
+
<FormField name="name" label="Name" />
|
|
1146
|
+
<FormField name="email" label="Email" type="email" />
|
|
1147
|
+
<FormErrorSummary />
|
|
1148
|
+
<button type="submit">Submit</button>
|
|
1149
|
+
</Form>
|
|
1150
|
+
);
|
|
1151
|
+
|
|
1152
|
+
// Submit empty form
|
|
1153
|
+
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
|
1154
|
+
|
|
1155
|
+
expect(onError).toHaveBeenCalled();
|
|
1156
|
+
expect(screen.getAllByText('Required')).toHaveLength(2);
|
|
1157
|
+
});
|
|
1158
|
+
});
|