@jmruthers/pace-core 0.5.64 → 0.5.67
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-7BER7PDS.js → DataTable-MFUXNGPR.js} +2 -2
- package/dist/{DataTable-D15XipLZ.d.ts → DataTable-ntgmhO2W.d.ts} +1 -1
- package/dist/{PublicLoadingSpinner-CXJ-W9wZ.d.ts → PublicLoadingSpinner-DdKXTkCZ.d.ts} +94 -1
- package/dist/{chunk-S66AJVI2.js → chunk-4HQ5BOVZ.js} +97 -27
- package/dist/chunk-4HQ5BOVZ.js.map +1 -0
- package/dist/{chunk-2LPYEFXI.js → chunk-ZB6AEA7I.js} +309 -261
- package/dist/chunk-ZB6AEA7I.js.map +1 -0
- package/dist/components.d.ts +4 -3
- package/dist/components.js +4 -2
- package/dist/components.js.map +1 -1
- package/dist/hooks.d.ts +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/{types-E5WSpEtz.d.ts → types-CGX9Vyf5.d.ts} +8 -0
- package/dist/types.js.map +1 -1
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +1 -1
- package/docs/api/classes/ColumnFactory.md +6 -6
- package/docs/api/classes/ErrorBoundary.md +1 -1
- package/docs/api/classes/InvalidScopeError.md +1 -1
- package/docs/api/classes/MissingUserContextError.md +1 -1
- package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
- package/docs/api/classes/PermissionDeniedError.md +1 -1
- package/docs/api/classes/PublicErrorBoundary.md +1 -1
- package/docs/api/classes/RBACAuditManager.md +1 -1
- package/docs/api/classes/RBACCache.md +1 -1
- package/docs/api/classes/RBACEngine.md +1 -1
- package/docs/api/classes/RBACError.md +1 -1
- package/docs/api/classes/RBACNotInitializedError.md +1 -1
- package/docs/api/classes/SecureSupabaseClient.md +1 -1
- package/docs/api/classes/StorageUtils.md +1 -1
- package/docs/api/interfaces/AggregateConfig.md +4 -4
- 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 +1 -1
- package/docs/api/interfaces/DataTableAction.md +14 -14
- package/docs/api/interfaces/DataTableColumn.md +21 -21
- package/docs/api/interfaces/DataTableProps.md +1 -1
- package/docs/api/interfaces/DataTableToolbarButton.md +7 -7
- package/docs/api/interfaces/EmptyStateConfig.md +5 -5
- package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
- 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 +1 -1
- package/docs/api/interfaces/NavigationContextType.md +1 -1
- package/docs/api/interfaces/NavigationGuardProps.md +1 -1
- package/docs/api/interfaces/NavigationItem.md +1 -1
- package/docs/api/interfaces/NavigationMenuProps.md +1 -1
- package/docs/api/interfaces/NavigationProviderProps.md +1 -1
- package/docs/api/interfaces/Organisation.md +1 -1
- package/docs/api/interfaces/OrganisationContextType.md +1 -1
- package/docs/api/interfaces/OrganisationMembership.md +1 -1
- 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 +1 -1
- package/docs/api/interfaces/PagePermissionContextType.md +1 -1
- package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
- package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
- package/docs/api/interfaces/PaletteData.md +1 -1
- package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
- 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 +1 -1
- package/docs/api/interfaces/RBACContextType.md +1 -1
- package/docs/api/interfaces/RBACLogger.md +1 -1
- package/docs/api/interfaces/RBACProviderProps.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
- package/docs/api/interfaces/RouteAccessRecord.md +1 -1
- package/docs/api/interfaces/RouteConfig.md +1 -1
- package/docs/api/interfaces/SecureDataContextType.md +1 -1
- package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
- 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/SwitchProps.md +34 -0
- package/docs/api/interfaces/ToastActionElement.md +1 -1
- package/docs/api/interfaces/ToastProps.md +1 -1
- package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
- 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 +1 -1
- package/docs/api/interfaces/UserMenuProps.md +1 -1
- package/docs/api/interfaces/UserProfile.md +1 -1
- package/docs/api/modules.md +38 -4
- package/package.json +2 -1
- package/src/components/DataTable/components/DataTableBody.tsx +27 -11
- package/src/components/DataTable/components/DataTableCore.tsx +13 -13
- package/src/components/DataTable/components/EditableRow.tsx +46 -28
- package/src/components/DataTable/components/UnifiedTableBody.tsx +86 -38
- package/src/components/DataTable/components/VirtualizedDataTable.tsx +5 -3
- package/src/components/DataTable/core/ColumnFactory.ts +4 -0
- package/src/components/DataTable/types.ts +10 -0
- package/src/components/Switch/Switch.test.tsx +438 -0
- package/src/components/Switch/Switch.tsx +140 -0
- package/src/components/Switch/index.ts +9 -0
- package/src/components/index.ts +2 -0
- package/src/index.ts +2 -0
- package/src/types/index.ts +2 -0
- package/dist/chunk-2LPYEFXI.js.map +0 -1
- package/dist/chunk-S66AJVI2.js.map +0 -1
- /package/dist/{DataTable-7BER7PDS.js.map → DataTable-MFUXNGPR.js.map} +0 -0
|
@@ -32,7 +32,9 @@ const MemoizedCell = memo(({ cell, style }: { cell: any; style?: React.CSSProper
|
|
|
32
32
|
)}
|
|
33
33
|
style={style}
|
|
34
34
|
>
|
|
35
|
-
{
|
|
35
|
+
<div className={cell.column?.columnDef?.meta?.align === 'right' ? 'text-right' : ''}>
|
|
36
|
+
{flexRender(cell.column?.columnDef?.cell, cell.getContext?.() || {})}
|
|
37
|
+
</div>
|
|
36
38
|
</td>
|
|
37
39
|
);
|
|
38
40
|
});
|
|
@@ -185,7 +187,7 @@ export function VirtualizedDataTable<TData extends DataRecord>({
|
|
|
185
187
|
style={{}}
|
|
186
188
|
onClick={header.column?.getToggleSortingHandler ? header.column.getToggleSortingHandler() : undefined}
|
|
187
189
|
>
|
|
188
|
-
<div className=
|
|
190
|
+
<div className={`flex items-center space-x-1 ${header.column?.columnDef?.meta?.align === 'right' ? 'justify-end' : ''}`}>
|
|
189
191
|
{header.isPlaceholder
|
|
190
192
|
? null
|
|
191
193
|
: flexRender(header.column?.columnDef?.header, header.getContext?.() || {})}
|
|
@@ -413,7 +415,7 @@ export function EnhancedVirtualizedDataTable<TData extends DataRecord>({
|
|
|
413
415
|
style={{}}
|
|
414
416
|
onClick={header.column?.getToggleSortingHandler ? header.column.getToggleSortingHandler() : undefined}
|
|
415
417
|
>
|
|
416
|
-
<div className=
|
|
418
|
+
<div className={`flex items-center space-x-1 ${header.column?.columnDef?.meta?.align === 'right' ? 'justify-end' : ''}`}>
|
|
417
419
|
{header.isPlaceholder
|
|
418
420
|
? null
|
|
419
421
|
: flexRender(header.column?.columnDef?.header, header.getContext?.() || {})}
|
|
@@ -120,6 +120,10 @@ export class ColumnFactory<TData extends DataRecord = DataRecord> {
|
|
|
120
120
|
size: options.size,
|
|
121
121
|
minSize: options.minSize,
|
|
122
122
|
maxSize: options.maxSize,
|
|
123
|
+
meta: {
|
|
124
|
+
align: 'right' as const,
|
|
125
|
+
type: 'number' as const,
|
|
126
|
+
},
|
|
123
127
|
} as ColumnDef<TData>;
|
|
124
128
|
}
|
|
125
129
|
|
|
@@ -17,6 +17,16 @@ import type {
|
|
|
17
17
|
} from '@tanstack/react-table';
|
|
18
18
|
import type { ImportModalConfig } from './components/ImportModal';
|
|
19
19
|
|
|
20
|
+
// Extend TanStack Table types to include custom meta properties
|
|
21
|
+
declare module '@tanstack/react-table' {
|
|
22
|
+
interface ColumnMeta<TData, TValue> {
|
|
23
|
+
/** Text alignment for the column content */
|
|
24
|
+
align?: 'left' | 'right' | 'center';
|
|
25
|
+
/** Column type for styling purposes */
|
|
26
|
+
type?: 'text' | 'number' | 'date' | 'boolean' | 'custom';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
// ============================================================================
|
|
21
31
|
// CORE DATA TYPES
|
|
22
32
|
// ============================================================================
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Switch Component Tests
|
|
3
|
+
* @description Comprehensive tests for Switch component
|
|
4
|
+
* @package @jmruthers/pace-core
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import { screen } from '@testing-library/react';
|
|
9
|
+
import userEvent from '@testing-library/user-event';
|
|
10
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
11
|
+
import { Switch } from './Switch';
|
|
12
|
+
import { renderWithProviders } from '../../__tests__/helpers/test-utils';
|
|
13
|
+
|
|
14
|
+
describe('Switch Component', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.clearAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('Rendering', () => {
|
|
20
|
+
it('renders with default props', () => {
|
|
21
|
+
renderWithProviders(<Switch />);
|
|
22
|
+
const switchElement = screen.getByRole('switch');
|
|
23
|
+
expect(switchElement).toBeInTheDocument();
|
|
24
|
+
expect(switchElement).not.toBeChecked();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('renders with custom className', () => {
|
|
28
|
+
renderWithProviders(<Switch className="custom-switch" />);
|
|
29
|
+
const switchElement = screen.getByRole('switch');
|
|
30
|
+
expect(switchElement).toHaveClass('custom-switch');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('renders with custom id', () => {
|
|
34
|
+
renderWithProviders(<Switch id="test-switch" />);
|
|
35
|
+
const switchElement = screen.getByRole('switch');
|
|
36
|
+
expect(switchElement).toHaveAttribute('id', 'test-switch');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('forwards ref correctly', () => {
|
|
40
|
+
const ref = React.createRef<HTMLButtonElement>();
|
|
41
|
+
renderWithProviders(<Switch ref={ref} />);
|
|
42
|
+
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('applies all HTML attributes', () => {
|
|
46
|
+
renderWithProviders(
|
|
47
|
+
<Switch
|
|
48
|
+
data-testid="test-switch"
|
|
49
|
+
aria-label="Test switch"
|
|
50
|
+
title="Test title"
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
const switchElement = screen.getByRole('switch');
|
|
54
|
+
expect(switchElement).toHaveAttribute('data-testid', 'test-switch');
|
|
55
|
+
expect(switchElement).toHaveAttribute('aria-label', 'Test switch');
|
|
56
|
+
expect(switchElement).toHaveAttribute('title', 'Test title');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('State Management', () => {
|
|
61
|
+
it('renders as unchecked by default', () => {
|
|
62
|
+
renderWithProviders(<Switch />);
|
|
63
|
+
const switchElement = screen.getByRole('switch');
|
|
64
|
+
expect(switchElement).not.toBeChecked();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('renders as checked when checked prop is true', () => {
|
|
68
|
+
renderWithProviders(<Switch checked={true} />);
|
|
69
|
+
const switchElement = screen.getByRole('switch');
|
|
70
|
+
expect(switchElement).toBeChecked();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('renders as unchecked when checked prop is false', () => {
|
|
74
|
+
renderWithProviders(<Switch checked={false} />);
|
|
75
|
+
const switchElement = screen.getByRole('switch');
|
|
76
|
+
expect(switchElement).not.toBeChecked();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('handles controlled state changes', () => {
|
|
80
|
+
const handleCheckedChange = vi.fn();
|
|
81
|
+
renderWithProviders(
|
|
82
|
+
<Switch
|
|
83
|
+
checked={false}
|
|
84
|
+
onCheckedChange={handleCheckedChange}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
const switchElement = screen.getByRole('switch');
|
|
88
|
+
expect(switchElement).not.toBeChecked();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('Disabled State', () => {
|
|
93
|
+
it('renders as disabled when disabled prop is true', () => {
|
|
94
|
+
renderWithProviders(<Switch disabled={true} />);
|
|
95
|
+
const switchElement = screen.getByRole('switch');
|
|
96
|
+
expect(switchElement).toBeDisabled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('renders as enabled when disabled prop is false', () => {
|
|
100
|
+
renderWithProviders(<Switch disabled={false} />);
|
|
101
|
+
const switchElement = screen.getByRole('switch');
|
|
102
|
+
expect(switchElement).not.toBeDisabled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('applies disabled styling', () => {
|
|
106
|
+
renderWithProviders(<Switch disabled={true} />);
|
|
107
|
+
const switchElement = screen.getByRole('switch');
|
|
108
|
+
expect(switchElement).toHaveClass('disabled:cursor-not-allowed', 'disabled:opacity-50');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('Event Handling', () => {
|
|
113
|
+
it('handles click events', async () => {
|
|
114
|
+
const handleCheckedChange = vi.fn();
|
|
115
|
+
const user = userEvent.setup();
|
|
116
|
+
|
|
117
|
+
renderWithProviders(
|
|
118
|
+
<Switch onCheckedChange={handleCheckedChange} />
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const switchElement = screen.getByRole('switch');
|
|
122
|
+
await user.click(switchElement);
|
|
123
|
+
|
|
124
|
+
expect(handleCheckedChange).toHaveBeenCalledTimes(1);
|
|
125
|
+
expect(handleCheckedChange).toHaveBeenCalledWith(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('handles keyboard events (Space)', async () => {
|
|
129
|
+
const handleCheckedChange = vi.fn();
|
|
130
|
+
const user = userEvent.setup();
|
|
131
|
+
|
|
132
|
+
renderWithProviders(
|
|
133
|
+
<Switch onCheckedChange={handleCheckedChange} />
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const switchElement = screen.getByRole('switch');
|
|
137
|
+
switchElement.focus();
|
|
138
|
+
await user.keyboard(' ');
|
|
139
|
+
|
|
140
|
+
expect(handleCheckedChange).toHaveBeenCalledTimes(1);
|
|
141
|
+
expect(handleCheckedChange).toHaveBeenCalledWith(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('handles keyboard events (Enter)', async () => {
|
|
145
|
+
const handleCheckedChange = vi.fn();
|
|
146
|
+
const user = userEvent.setup();
|
|
147
|
+
|
|
148
|
+
renderWithProviders(
|
|
149
|
+
<Switch onCheckedChange={handleCheckedChange} />
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const switchElement = screen.getByRole('switch');
|
|
153
|
+
switchElement.focus();
|
|
154
|
+
await user.keyboard('{Enter}');
|
|
155
|
+
|
|
156
|
+
expect(handleCheckedChange).toHaveBeenCalledTimes(1);
|
|
157
|
+
expect(handleCheckedChange).toHaveBeenCalledWith(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('toggles state on click', async () => {
|
|
161
|
+
const handleCheckedChange = vi.fn();
|
|
162
|
+
const user = userEvent.setup();
|
|
163
|
+
|
|
164
|
+
const { rerender } = renderWithProviders(
|
|
165
|
+
<Switch checked={false} onCheckedChange={handleCheckedChange} />
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const switchElement = screen.getByRole('switch');
|
|
169
|
+
await user.click(switchElement);
|
|
170
|
+
|
|
171
|
+
expect(handleCheckedChange).toHaveBeenCalledWith(true);
|
|
172
|
+
|
|
173
|
+
// Simulate state change
|
|
174
|
+
rerender(<Switch checked={true} onCheckedChange={handleCheckedChange} />);
|
|
175
|
+
expect(switchElement).toBeChecked();
|
|
176
|
+
|
|
177
|
+
await user.click(switchElement);
|
|
178
|
+
expect(handleCheckedChange).toHaveBeenCalledWith(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('does not trigger events when disabled', async () => {
|
|
182
|
+
const handleCheckedChange = vi.fn();
|
|
183
|
+
const user = userEvent.setup();
|
|
184
|
+
|
|
185
|
+
renderWithProviders(
|
|
186
|
+
<Switch disabled={true} onCheckedChange={handleCheckedChange} />
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const switchElement = screen.getByRole('switch');
|
|
190
|
+
await user.click(switchElement);
|
|
191
|
+
|
|
192
|
+
expect(handleCheckedChange).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('Accessibility', () => {
|
|
197
|
+
it('has proper ARIA attributes', () => {
|
|
198
|
+
renderWithProviders(<Switch />);
|
|
199
|
+
const switchElement = screen.getByRole('switch');
|
|
200
|
+
expect(switchElement).toHaveAttribute('type', 'button');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('supports aria-label', () => {
|
|
204
|
+
renderWithProviders(<Switch aria-label="Toggle notifications" />);
|
|
205
|
+
const switchElement = screen.getByRole('switch', { name: 'Toggle notifications' });
|
|
206
|
+
expect(switchElement).toBeInTheDocument();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('supports aria-labelledby', () => {
|
|
210
|
+
renderWithProviders(
|
|
211
|
+
<div>
|
|
212
|
+
<label id="notifications-label">Enable notifications</label>
|
|
213
|
+
<Switch aria-labelledby="notifications-label" />
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
const switchElement = screen.getByRole('switch', { name: 'Enable notifications' });
|
|
217
|
+
expect(switchElement).toBeInTheDocument();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('supports aria-describedby', () => {
|
|
221
|
+
renderWithProviders(
|
|
222
|
+
<div>
|
|
223
|
+
<Switch aria-describedby="notifications-help" />
|
|
224
|
+
<div id="notifications-help">You will receive email notifications</div>
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
const switchElement = screen.getByRole('switch');
|
|
228
|
+
expect(switchElement).toHaveAttribute('aria-describedby', 'notifications-help');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('announces state changes to screen readers', () => {
|
|
232
|
+
const { rerender } = renderWithProviders(<Switch checked={false} />);
|
|
233
|
+
let switchElement = screen.getByRole('switch');
|
|
234
|
+
expect(switchElement).toHaveAttribute('aria-checked', 'false');
|
|
235
|
+
|
|
236
|
+
rerender(<Switch checked={true} />);
|
|
237
|
+
switchElement = screen.getByRole('switch');
|
|
238
|
+
expect(switchElement).toHaveAttribute('aria-checked', 'true');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('is keyboard accessible', () => {
|
|
242
|
+
renderWithProviders(<Switch />);
|
|
243
|
+
const switchElement = screen.getByRole('switch');
|
|
244
|
+
expect(switchElement).not.toHaveAttribute('tabindex', '-1');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('has focus visible styles', () => {
|
|
248
|
+
renderWithProviders(<Switch />);
|
|
249
|
+
const switchElement = screen.getByRole('switch');
|
|
250
|
+
expect(switchElement).toHaveClass('focus-visible:outline-none', 'focus-visible:ring-2');
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('Visual States', () => {
|
|
255
|
+
it('applies checked state styling', () => {
|
|
256
|
+
renderWithProviders(<Switch checked={true} />);
|
|
257
|
+
const switchElement = screen.getByRole('switch');
|
|
258
|
+
expect(switchElement).toHaveClass('data-[state=checked]:bg-main-600');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('applies unchecked state styling', () => {
|
|
262
|
+
renderWithProviders(<Switch checked={false} />);
|
|
263
|
+
const switchElement = screen.getByRole('switch');
|
|
264
|
+
expect(switchElement).toHaveClass('data-[state=unchecked]:bg-sec-200');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('has proper sizing', () => {
|
|
268
|
+
renderWithProviders(<Switch />);
|
|
269
|
+
const switchElement = screen.getByRole('switch');
|
|
270
|
+
expect(switchElement).toHaveClass('h-6', 'w-11');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('has proper border and background styling', () => {
|
|
274
|
+
renderWithProviders(<Switch />);
|
|
275
|
+
const switchElement = screen.getByRole('switch');
|
|
276
|
+
expect(switchElement).toHaveClass('rounded-full', 'border-2', 'border-transparent');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('has proper thumb styling', () => {
|
|
280
|
+
renderWithProviders(<Switch />);
|
|
281
|
+
const switchElement = screen.getByRole('switch');
|
|
282
|
+
const thumb = switchElement.querySelector('[data-state]');
|
|
283
|
+
expect(thumb).toHaveClass('h-5', 'w-5', 'rounded-full', 'bg-background');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('Form Integration', () => {
|
|
288
|
+
it('works with form labels', () => {
|
|
289
|
+
renderWithProviders(
|
|
290
|
+
<div>
|
|
291
|
+
<label htmlFor="notifications-switch">Enable notifications</label>
|
|
292
|
+
<Switch id="notifications-switch" />
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const switchElement = screen.getByRole('switch');
|
|
297
|
+
const label = screen.getByText('Enable notifications');
|
|
298
|
+
|
|
299
|
+
expect(switchElement).toHaveAttribute('id', 'notifications-switch');
|
|
300
|
+
expect(label).toHaveAttribute('for', 'notifications-switch');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('works with form submission', async () => {
|
|
304
|
+
const handleSubmit = vi.fn((e) => e.preventDefault());
|
|
305
|
+
const user = userEvent.setup();
|
|
306
|
+
|
|
307
|
+
renderWithProviders(
|
|
308
|
+
<form onSubmit={handleSubmit}>
|
|
309
|
+
<Switch name="notifications" />
|
|
310
|
+
<button type="submit">Submit</button>
|
|
311
|
+
</form>
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
|
315
|
+
expect(handleSubmit).toHaveBeenCalledTimes(1);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('works with form validation', () => {
|
|
319
|
+
renderWithProviders(
|
|
320
|
+
<Switch
|
|
321
|
+
aria-invalid={true}
|
|
322
|
+
aria-describedby="error-message"
|
|
323
|
+
/>
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const switchElement = screen.getByRole('switch');
|
|
327
|
+
expect(switchElement).toHaveAttribute('aria-invalid', 'true');
|
|
328
|
+
expect(switchElement).toHaveAttribute('aria-describedby', 'error-message');
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('Error Handling', () => {
|
|
333
|
+
it('handles missing onCheckedChange gracefully', () => {
|
|
334
|
+
renderWithProviders(<Switch checked={true} />);
|
|
335
|
+
const switchElement = screen.getByRole('switch');
|
|
336
|
+
expect(switchElement).toBeChecked();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('handles invalid props gracefully', () => {
|
|
340
|
+
// @ts-expect-error Testing invalid prop
|
|
341
|
+
renderWithProviders(<Switch invalidProp="test" />);
|
|
342
|
+
const switchElement = screen.getByRole('switch');
|
|
343
|
+
expect(switchElement).toBeInTheDocument();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe('Integration', () => {
|
|
348
|
+
it('works with multiple switches', () => {
|
|
349
|
+
renderWithProviders(
|
|
350
|
+
<div>
|
|
351
|
+
<Switch id="switch1" />
|
|
352
|
+
<Switch id="switch2" />
|
|
353
|
+
<Switch id="switch3" />
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
expect(screen.getAllByRole('switch')).toHaveLength(3);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('works with switch groups', () => {
|
|
361
|
+
renderWithProviders(
|
|
362
|
+
<fieldset>
|
|
363
|
+
<legend>Notification preferences</legend>
|
|
364
|
+
<Switch id="email" />
|
|
365
|
+
<label htmlFor="email">Email notifications</label>
|
|
366
|
+
<Switch id="sms" />
|
|
367
|
+
<label htmlFor="sms">SMS notifications</label>
|
|
368
|
+
</fieldset>
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const switches = screen.getAllByRole('switch');
|
|
372
|
+
expect(switches).toHaveLength(2);
|
|
373
|
+
expect(switches[0]).toHaveAttribute('id', 'email');
|
|
374
|
+
expect(switches[1]).toHaveAttribute('id', 'sms');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('works with controlled state management', async () => {
|
|
378
|
+
const TestComponent = () => {
|
|
379
|
+
const [checked, setChecked] = React.useState(false);
|
|
380
|
+
return (
|
|
381
|
+
<div>
|
|
382
|
+
<Switch
|
|
383
|
+
checked={checked}
|
|
384
|
+
onCheckedChange={setChecked}
|
|
385
|
+
/>
|
|
386
|
+
<span data-testid="status">{checked ? 'on' : 'off'}</span>
|
|
387
|
+
</div>
|
|
388
|
+
);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const user = userEvent.setup();
|
|
392
|
+
renderWithProviders(<TestComponent />);
|
|
393
|
+
|
|
394
|
+
const switchElement = screen.getByRole('switch');
|
|
395
|
+
const status = screen.getByTestId('status');
|
|
396
|
+
|
|
397
|
+
expect(status).toHaveTextContent('off');
|
|
398
|
+
|
|
399
|
+
await user.click(switchElement);
|
|
400
|
+
expect(status).toHaveTextContent('on');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('works with external state management', async () => {
|
|
404
|
+
const externalState = { checked: false };
|
|
405
|
+
const handleChange = vi.fn((checked) => {
|
|
406
|
+
externalState.checked = checked;
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const user = userEvent.setup();
|
|
410
|
+
renderWithProviders(
|
|
411
|
+
<Switch
|
|
412
|
+
checked={externalState.checked}
|
|
413
|
+
onCheckedChange={handleChange}
|
|
414
|
+
/>
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const switchElement = screen.getByRole('switch');
|
|
418
|
+
await user.click(switchElement);
|
|
419
|
+
|
|
420
|
+
expect(handleChange).toHaveBeenCalledWith(true);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe('Performance', () => {
|
|
425
|
+
it('renders efficiently with many switches', () => {
|
|
426
|
+
const switches = Array.from({ length: 100 }, (_, i) => (
|
|
427
|
+
<Switch key={i} id={`switch-${i}`} />
|
|
428
|
+
));
|
|
429
|
+
|
|
430
|
+
const startTime = performance.now();
|
|
431
|
+
renderWithProviders(<div>{switches}</div>);
|
|
432
|
+
const endTime = performance.now();
|
|
433
|
+
|
|
434
|
+
expect(screen.getAllByRole('switch')).toHaveLength(100);
|
|
435
|
+
expect(endTime - startTime).toBeLessThan(1000); // Should render in under 1 second
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Switch Component
|
|
3
|
+
* @package @jmruthers/pace-core
|
|
4
|
+
* @module Components/Switch
|
|
5
|
+
* @since 0.5.67
|
|
6
|
+
*
|
|
7
|
+
* A toggle switch component built on Radix UI primitives.
|
|
8
|
+
* Provides an accessible, keyboard-navigable switch for boolean states.
|
|
9
|
+
*
|
|
10
|
+
* Features:
|
|
11
|
+
* - WCAG 2.1 AA compliant
|
|
12
|
+
* - Keyboard navigation (Space/Enter)
|
|
13
|
+
* - Focus visible indicators
|
|
14
|
+
* - Disabled state support
|
|
15
|
+
* - Smooth animations
|
|
16
|
+
* - Tailwind v4 compatible
|
|
17
|
+
* - Theme-aware colors
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* import { Switch } from '@jmruthers/pace-core';
|
|
22
|
+
*
|
|
23
|
+
* function Example() {
|
|
24
|
+
* const [checked, setChecked] = React.useState(false);
|
|
25
|
+
*
|
|
26
|
+
* return (
|
|
27
|
+
* <div className="flex items-center gap-2">
|
|
28
|
+
* <Switch
|
|
29
|
+
* checked={checked}
|
|
30
|
+
* onCheckedChange={setChecked}
|
|
31
|
+
* id="notifications"
|
|
32
|
+
* />
|
|
33
|
+
* <label htmlFor="notifications">
|
|
34
|
+
* Enable notifications
|
|
35
|
+
* </label>
|
|
36
|
+
* </div>
|
|
37
|
+
* );
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @example With Form
|
|
42
|
+
* ```tsx
|
|
43
|
+
* import { Switch, Label } from '@jmruthers/pace-core';
|
|
44
|
+
*
|
|
45
|
+
* function FormExample() {
|
|
46
|
+
* return (
|
|
47
|
+
* <div className="flex items-center gap-2">
|
|
48
|
+
* <Switch id="terms" />
|
|
49
|
+
* <Label htmlFor="terms">
|
|
50
|
+
* I agree to the terms and conditions
|
|
51
|
+
* </Label>
|
|
52
|
+
* </div>
|
|
53
|
+
* );
|
|
54
|
+
* }
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @example Disabled State
|
|
58
|
+
* ```tsx
|
|
59
|
+
* <Switch disabled checked />
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* @accessibility
|
|
63
|
+
* - Uses Radix UI's accessible switch primitive
|
|
64
|
+
* - Proper keyboard navigation (Space to toggle)
|
|
65
|
+
* - Screen reader announcements for state changes
|
|
66
|
+
* - Focus visible styles for keyboard users
|
|
67
|
+
* - ARIA attributes handled automatically
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import * as React from "react";
|
|
71
|
+
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
|
72
|
+
import { cn } from "../../utils/cn";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Switch component props
|
|
76
|
+
* Extends all props from Radix UI Switch.Root
|
|
77
|
+
*/
|
|
78
|
+
export interface SwitchProps
|
|
79
|
+
extends React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root> {
|
|
80
|
+
/**
|
|
81
|
+
* Additional CSS classes to apply to the switch
|
|
82
|
+
*/
|
|
83
|
+
className?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Switch component
|
|
88
|
+
*
|
|
89
|
+
* A toggle switch for boolean states. Built on Radix UI for accessibility.
|
|
90
|
+
*
|
|
91
|
+
* @component
|
|
92
|
+
* @example
|
|
93
|
+
* ```tsx
|
|
94
|
+
* <Switch checked={isEnabled} onCheckedChange={setIsEnabled} />
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
const Switch = React.forwardRef<
|
|
98
|
+
React.ElementRef<typeof SwitchPrimitive.Root>,
|
|
99
|
+
SwitchProps
|
|
100
|
+
>(({ className, ...props }, ref) => (
|
|
101
|
+
<SwitchPrimitive.Root
|
|
102
|
+
className={cn(
|
|
103
|
+
// Base styles
|
|
104
|
+
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full",
|
|
105
|
+
// Border
|
|
106
|
+
"border-2 border-transparent",
|
|
107
|
+
// Transitions
|
|
108
|
+
"transition-colors",
|
|
109
|
+
// Focus styles
|
|
110
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-main-600",
|
|
111
|
+
"focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
112
|
+
// Disabled state
|
|
113
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
114
|
+
// State-based colors (using theme variables)
|
|
115
|
+
"data-[state=checked]:bg-main-600",
|
|
116
|
+
"data-[state=unchecked]:bg-sec-200",
|
|
117
|
+
className
|
|
118
|
+
)}
|
|
119
|
+
{...props}
|
|
120
|
+
ref={ref}
|
|
121
|
+
>
|
|
122
|
+
<SwitchPrimitive.Thumb
|
|
123
|
+
className={cn(
|
|
124
|
+
// Base styles
|
|
125
|
+
"pointer-events-none block h-5 w-5 rounded-full",
|
|
126
|
+
// Background and shadow
|
|
127
|
+
"bg-background shadow-lg ring-0",
|
|
128
|
+
// Transition
|
|
129
|
+
"transition-transform",
|
|
130
|
+
// State-based position
|
|
131
|
+
"data-[state=checked]:translate-x-5",
|
|
132
|
+
"data-[state=unchecked]:translate-x-0"
|
|
133
|
+
)}
|
|
134
|
+
/>
|
|
135
|
+
</SwitchPrimitive.Root>
|
|
136
|
+
));
|
|
137
|
+
|
|
138
|
+
Switch.displayName = SwitchPrimitive.Root.displayName;
|
|
139
|
+
|
|
140
|
+
export { Switch };
|
package/src/components/index.ts
CHANGED
|
@@ -50,6 +50,8 @@ export { Alert, AlertTitle, AlertDescription } from './Alert';
|
|
|
50
50
|
export { Avatar, AvatarImage, AvatarFallback } from './Avatar';
|
|
51
51
|
|
|
52
52
|
export { Checkbox } from './Checkbox';
|
|
53
|
+
export { Switch } from './Switch';
|
|
54
|
+
export type { SwitchProps } from './Switch';
|
|
53
55
|
export { Progress } from './Progress';
|
|
54
56
|
|
|
55
57
|
// Table components
|
package/src/index.ts
CHANGED
|
@@ -62,6 +62,8 @@ export { Alert, AlertTitle, AlertDescription } from './components/Alert/Alert';
|
|
|
62
62
|
export { Avatar, AvatarImage, AvatarFallback } from './components/Avatar/Avatar';
|
|
63
63
|
|
|
64
64
|
export { Checkbox } from './components/Checkbox/Checkbox';
|
|
65
|
+
export { Switch } from './components/Switch/Switch';
|
|
66
|
+
export type { SwitchProps } from './components/Switch/Switch';
|
|
65
67
|
export { Progress } from './components/Progress/Progress';
|
|
66
68
|
|
|
67
69
|
// ADVANCED UI COMPONENTS
|