@jmruthers/pace-core 0.5.115 → 0.5.116
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/{AuthService-CVgsgtaZ.d.ts → AuthService-D4646R4b.d.ts} +9 -4
- package/dist/{DataTable-H5KJCAIS.js → DataTable-ZOAKQ3SU.js} +10 -9
- package/dist/{UnifiedAuthProvider-KZZUO27W.js → UnifiedAuthProvider-YFN7YGVN.js} +4 -3
- package/dist/{api-PKU4PUBO.js → api-TNIBJWLM.js} +3 -3
- package/dist/{audit-H4YJJF7R.js → audit-T36HM7IM.js} +2 -2
- package/dist/{chunk-SYXOZQ4P.js → chunk-2GJ5GL77.js} +1 -1
- package/dist/chunk-2GJ5GL77.js.map +1 -0
- package/dist/{chunk-XYRZV7R5.js → chunk-2LM4QQGH.js} +30 -34
- package/dist/chunk-2LM4QQGH.js.map +1 -0
- package/dist/{chunk-3OGQLOJM.js → chunk-3DBFLLLU.js} +30 -1
- package/dist/chunk-3DBFLLLU.js.map +1 -0
- package/dist/{chunk-KTHLNIMA.js → chunk-ECOVPXYS.js} +13 -62
- package/dist/chunk-ECOVPXYS.js.map +1 -0
- package/dist/{chunk-OO3V7W4H.js → chunk-KA3PSVNV.js} +87 -40
- package/dist/chunk-KA3PSVNV.js.map +1 -0
- package/dist/{chunk-HKWQN44G.js → chunk-KMPWND3F.js} +15 -15
- package/dist/{chunk-L36JW4KV.js → chunk-LFS45U62.js} +2 -2
- package/dist/{chunk-NEONKMTU.js → chunk-LZYHAL7Y.js} +9 -4
- package/dist/{chunk-NEONKMTU.js.map → chunk-LZYHAL7Y.js.map} +1 -1
- package/dist/{chunk-BUN7NMV7.js → chunk-O3FTRYEU.js} +2 -2
- package/dist/{chunk-F6QB26OS.js → chunk-P3PUOL6B.js} +80 -8
- package/dist/chunk-P3PUOL6B.js.map +1 -0
- package/dist/{chunk-ZPXWJA4H.js → chunk-PHDAXDHB.js} +131 -5
- package/dist/chunk-PHDAXDHB.js.map +1 -0
- package/dist/chunk-UJI6WSMD.js +201 -0
- package/dist/{chunk-5CDJCTOO.js.map → chunk-UJI6WSMD.js.map} +1 -1
- package/dist/{chunk-OUU3SP6I.js → chunk-UKZWNQMB.js} +50 -7
- package/dist/{chunk-OUU3SP6I.js.map → chunk-UKZWNQMB.js.map} +1 -1
- package/dist/{chunk-7H75SHXZ.js → chunk-VN3OOE35.js} +2 -2
- package/dist/{chunk-QKIVSZ2O.js → chunk-WP5I5GLN.js} +2 -2
- package/dist/components.d.ts +1 -1
- package/dist/components.js +12 -11
- package/dist/components.js.map +1 -1
- package/dist/hooks.d.ts +1 -1
- package/dist/hooks.js +10 -9
- package/dist/hooks.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +19 -16
- package/dist/index.js.map +1 -1
- package/dist/providers.d.ts +2 -2
- package/dist/providers.js +3 -2
- package/dist/rbac/index.d.ts +82 -1
- package/dist/rbac/index.js +13 -10
- package/dist/{useToast-DVT4dMtf.d.ts → useToast-Cs_g32bg.d.ts} +1 -1
- package/dist/utils.js +6 -4
- package/dist/utils.js.map +1 -1
- package/dist/validation.js +3 -1
- package/dist/validation.js.map +1 -1
- package/docs/README.md +4 -0
- package/docs/api/classes/ColumnFactory.md +1 -1
- 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 +35 -12
- 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/enums/FileCategory.md +1 -1
- 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 +1 -1
- package/docs/api/interfaces/DataRecord.md +1 -1
- 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 +1 -1
- package/docs/api/interfaces/EventAppRoleData.md +71 -0
- package/docs/api/interfaces/FileDisplayProps.md +1 -1
- package/docs/api/interfaces/FileMetadata.md +1 -1
- package/docs/api/interfaces/FileReference.md +1 -1
- package/docs/api/interfaces/FileSizeLimits.md +1 -1
- package/docs/api/interfaces/FileUploadOptions.md +1 -1
- package/docs/api/interfaces/FileUploadProps.md +1 -1
- package/docs/api/interfaces/FooterProps.md +1 -1
- package/docs/api/interfaces/GrantEventAppRoleParams.md +122 -0
- 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 +27 -27
- 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/ProtectedRouteProps.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/RBACLogger.md +1 -1
- package/docs/api/interfaces/RevokeEventAppRoleParams.md +100 -0
- package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
- package/docs/api/interfaces/RoleManagementResult.md +52 -0
- 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 +1 -1
- 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/UsePublicEventOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
- package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
- package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
- package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
- package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
- package/docs/api/interfaces/UseResolvedScopeReturn.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 +41 -14
- package/docs/architecture/rpc-function-standards.md +193 -0
- package/package.json +1 -1
- package/src/__tests__/TEST_STANDARD.md +244 -2
- package/src/components/DataTable/__tests__/a11y.basic.test.tsx +46 -16
- package/src/components/DataTable/__tests__/keyboard.test.tsx +276 -217
- package/src/components/DataTable/components/DataTableCore.tsx +29 -2
- package/src/components/DataTable/components/DataTableToolbar.tsx +3 -2
- package/src/components/DataTable/components/EditableRow.tsx +18 -1
- package/src/components/DataTable/components/ViewRowModal.tsx +1 -1
- package/src/components/DataTable/components/__tests__/AccessDeniedPage.test.tsx +735 -0
- package/src/components/DataTable/components/__tests__/BulkOperationsDropdown.test.tsx +572 -0
- package/src/components/DataTable/components/__tests__/ColumnVisibilityDropdown.test.tsx +708 -0
- package/src/components/DataTable/components/__tests__/DataTableErrorBoundary.test.tsx +451 -0
- package/src/components/DataTable/components/__tests__/DataTableModals.test.tsx +456 -0
- package/src/components/DataTable/components/__tests__/EditableRow.test.tsx +454 -0
- package/src/components/DataTable/components/__tests__/ExpandButton.test.tsx +462 -0
- package/src/components/DataTable/components/__tests__/FilterRow.test.tsx +423 -0
- package/src/components/DataTable/components/__tests__/GroupHeader.test.tsx +393 -0
- package/src/components/DataTable/components/__tests__/GroupingDropdown.test.tsx +617 -0
- package/src/components/DataTable/components/__tests__/ImportModal.test.tsx +734 -0
- package/src/components/DataTable/components/__tests__/ViewRowModal.test.tsx +412 -0
- package/src/components/DataTable/hooks/useTableHandlers.ts +4 -0
- package/src/components/EventSelector/EventSelector.tsx +5 -25
- package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +12 -7
- package/src/components/PaceAppLayout/PaceAppLayout.tsx +4 -0
- package/src/components/PaceAppLayout/__tests__/PaceAppLayout.accessibility.test.tsx +7 -2
- package/src/components/PaceAppLayout/__tests__/PaceAppLayout.integration.test.tsx +13 -8
- package/src/components/PaceAppLayout/__tests__/PaceAppLayout.performance.test.tsx +109 -100
- package/src/components/PaceAppLayout/__tests__/PaceAppLayout.security.test.tsx +18 -13
- package/src/components/PaceAppLayout/__tests__/PaceAppLayout.unit.test.tsx +17 -12
- package/src/components/PaceLoginPage/PaceLoginPage.test.tsx +2 -0
- package/src/components/PaceLoginPage/PaceLoginPage.tsx +11 -1
- package/src/components/PasswordReset/PasswordChangeForm.test.tsx +2 -2
- package/src/components/ProtectedRoute/ProtectedRoute.test.tsx +648 -0
- package/src/components/ProtectedRoute/ProtectedRoute.tsx +10 -7
- package/src/components/PublicLayout/__tests__/PublicErrorBoundary.test.tsx +4 -12
- package/src/components/Select/Select.tsx +8 -0
- package/src/components/Toast/Toast.tsx +1 -1
- package/src/hooks/__tests__/usePublicEvent.simple.test.ts +367 -3
- package/src/hooks/__tests__/usePublicFileDisplay.test.ts +916 -0
- package/src/hooks/useEventTheme.ts +49 -18
- package/src/hooks/usePermissionCache.ts +5 -3
- package/src/hooks/useSecureDataAccess.ts +11 -1
- package/src/hooks/useToast.ts +1 -1
- package/src/providers/services/EventServiceProvider.tsx +15 -8
- package/src/rbac/__tests__/cache-invalidation.test.ts +385 -0
- package/src/rbac/audit.test.ts +206 -0
- package/src/rbac/audit.ts +37 -2
- package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +26 -23
- package/src/rbac/errors.test.ts +340 -0
- package/src/rbac/hooks/index.ts +9 -0
- package/src/rbac/hooks/useResolvedScope.test.ts +1063 -0
- package/src/rbac/hooks/useRoleManagement.test.ts +908 -0
- package/src/rbac/hooks/useRoleManagement.ts +255 -0
- package/src/services/AuthService.ts +10 -0
- package/src/services/EventService.ts +111 -50
- package/src/services/__tests__/AuthService.test.ts +1 -1
- package/src/services/__tests__/EventService.test.ts +60 -45
- package/src/services/interfaces/IEventService.ts +1 -1
- package/src/utils/__tests__/deviceFingerprint.unit.test.ts +320 -0
- package/src/utils/__tests__/logger.unit.test.ts +398 -0
- package/src/utils/__tests__/validation.unit.test.ts +225 -1
- package/src/utils/file-reference.test.ts +214 -0
- package/dist/chunk-3OGQLOJM.js.map +0 -1
- package/dist/chunk-5CDJCTOO.js +0 -190
- package/dist/chunk-F6QB26OS.js.map +0 -1
- package/dist/chunk-KTHLNIMA.js.map +0 -1
- package/dist/chunk-OO3V7W4H.js.map +0 -1
- package/dist/chunk-SYXOZQ4P.js.map +0 -1
- package/dist/chunk-XYRZV7R5.js.map +0 -1
- package/dist/chunk-ZPXWJA4H.js.map +0 -1
- package/src/rbac/audit-enhanced.ts +0 -351
- /package/dist/{DataTable-H5KJCAIS.js.map → DataTable-ZOAKQ3SU.js.map} +0 -0
- /package/dist/{UnifiedAuthProvider-KZZUO27W.js.map → UnifiedAuthProvider-YFN7YGVN.js.map} +0 -0
- /package/dist/{api-PKU4PUBO.js.map → api-TNIBJWLM.js.map} +0 -0
- /package/dist/{audit-H4YJJF7R.js.map → audit-T36HM7IM.js.map} +0 -0
- /package/dist/{chunk-HKWQN44G.js.map → chunk-KMPWND3F.js.map} +0 -0
- /package/dist/{chunk-L36JW4KV.js.map → chunk-LFS45U62.js.map} +0 -0
- /package/dist/{chunk-BUN7NMV7.js.map → chunk-O3FTRYEU.js.map} +0 -0
- /package/dist/{chunk-7H75SHXZ.js.map → chunk-VN3OOE35.js.map} +0 -0
- /package/dist/{chunk-QKIVSZ2O.js.map → chunk-WP5I5GLN.js.map} +0 -0
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import React from 'react';
|
|
12
|
-
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
12
|
+
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
|
13
13
|
import userEvent from '@testing-library/user-event';
|
|
14
14
|
import { vi, beforeEach, afterEach } from 'vitest';
|
|
15
15
|
import { DataTable } from '../DataTable';
|
|
16
16
|
import { MockRBACProvider } from './mocks/MockRBACProvider';
|
|
17
17
|
import type { DataTableColumn } from '../types';
|
|
18
|
+
import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
|
|
18
19
|
|
|
19
20
|
// Mock the RBAC hooks
|
|
20
21
|
vi.mock('../../../rbac/hooks', () => ({
|
|
@@ -106,6 +107,88 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
|
106
107
|
</MockRBACProvider>
|
|
107
108
|
);
|
|
108
109
|
|
|
110
|
+
const focusFirstFocusableInCell = (cell: HTMLElement) => {
|
|
111
|
+
const focusable = cell.querySelector<HTMLElement>('button, [tabindex], input, a, textarea, select');
|
|
112
|
+
if (focusable) {
|
|
113
|
+
focusable.focus();
|
|
114
|
+
return focusable;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
cell.setAttribute('tabindex', '-1');
|
|
118
|
+
cell.focus();
|
|
119
|
+
return cell;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const getFocusedCellCoordinates = () => {
|
|
123
|
+
const rows = screen.getAllByRole('row').slice(1);
|
|
124
|
+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
|
125
|
+
const cells = within(rows[rowIndex]).getAllByRole('cell');
|
|
126
|
+
for (let columnIndex = 0; columnIndex < cells.length; columnIndex++) {
|
|
127
|
+
if (cells[columnIndex].contains(document.activeElement)) {
|
|
128
|
+
return { rowIndex, columnIndex };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const getActiveHeaderLabel = () => {
|
|
136
|
+
const headers = screen.getAllByRole('columnheader');
|
|
137
|
+
const activeHeader = headers.find(header => header.contains(document.activeElement));
|
|
138
|
+
return activeHeader?.textContent?.toLowerCase().trim() ?? null;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const KeyboardNavigationTestTable: React.FC = () => {
|
|
142
|
+
const tableRef = React.useRef<HTMLTableElement>(null);
|
|
143
|
+
const keyboardNavigation = useKeyboardNavigation(2, 3, { tableRef });
|
|
144
|
+
|
|
145
|
+
const headers = ['Name', 'Email', 'Role'];
|
|
146
|
+
const rows = [
|
|
147
|
+
['Alice', 'alice@example.com', 'Admin'],
|
|
148
|
+
['Bob', 'bob@example.com', 'User'],
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<table ref={tableRef}>
|
|
153
|
+
<thead>
|
|
154
|
+
<tr role="row">
|
|
155
|
+
{headers.map((label, index) => (
|
|
156
|
+
<th
|
|
157
|
+
key={label}
|
|
158
|
+
role="columnheader"
|
|
159
|
+
tabIndex={0}
|
|
160
|
+
{...keyboardNavigation.getHeaderKeyboardHandlers(index)}
|
|
161
|
+
>
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
{...keyboardNavigation.getHeaderKeyboardHandlers(index)}
|
|
165
|
+
>
|
|
166
|
+
{label}
|
|
167
|
+
</button>
|
|
168
|
+
</th>
|
|
169
|
+
))}
|
|
170
|
+
</tr>
|
|
171
|
+
</thead>
|
|
172
|
+
<tbody>
|
|
173
|
+
{rows.map((cells, rowIndex) => (
|
|
174
|
+
<tr role="row" key={cells[0]}>
|
|
175
|
+
{cells.map((value, columnIndex) => (
|
|
176
|
+
<td
|
|
177
|
+
key={columnIndex}
|
|
178
|
+
role="cell"
|
|
179
|
+
tabIndex={keyboardNavigation.getCellTabIndex(rowIndex, columnIndex)}
|
|
180
|
+
{...keyboardNavigation.getCellKeyboardHandlers(rowIndex, columnIndex)}
|
|
181
|
+
>
|
|
182
|
+
{value}
|
|
183
|
+
</td>
|
|
184
|
+
))}
|
|
185
|
+
</tr>
|
|
186
|
+
))}
|
|
187
|
+
</tbody>
|
|
188
|
+
</table>
|
|
189
|
+
);
|
|
190
|
+
};
|
|
191
|
+
|
|
109
192
|
describe('DataTable Keyboard Navigation', () => {
|
|
110
193
|
beforeEach(() => {
|
|
111
194
|
// Clear any existing live regions
|
|
@@ -193,209 +276,168 @@ describe('DataTable Keyboard Navigation', () => {
|
|
|
193
276
|
});
|
|
194
277
|
});
|
|
195
278
|
|
|
196
|
-
it
|
|
197
|
-
const user = userEvent.setup();
|
|
198
|
-
|
|
199
|
-
render(
|
|
200
|
-
<TestWrapper>
|
|
201
|
-
<DataTable
|
|
202
|
-
data={testUsers}
|
|
203
|
-
columns={testColumns}
|
|
204
|
-
rbac={{ pageId: 'test-page' }}
|
|
205
|
-
features={defaultFeatures}
|
|
206
|
-
/>
|
|
207
|
-
</TestWrapper>
|
|
208
|
-
);
|
|
279
|
+
it('should navigate between headers with arrow keys', async () => {
|
|
280
|
+
const user = userEvent.setup({ delay: 0 });
|
|
209
281
|
|
|
210
|
-
const nameHeader = screen.getByRole('columnheader', { name: /name/i });
|
|
211
|
-
const emailHeader = screen.getByRole('columnheader', { name: /email/i });
|
|
212
|
-
|
|
213
|
-
// Focus the first header
|
|
214
|
-
nameHeader.focus();
|
|
215
|
-
expect(nameHeader).toHaveFocus();
|
|
216
282
|
|
|
217
|
-
|
|
283
|
+
render(<KeyboardNavigationTestTable />);
|
|
284
|
+
|
|
285
|
+
const headers = screen.getAllByRole('columnheader');
|
|
286
|
+
const nameIndex = headers.findIndex(header => /name/i.test(header.textContent || ''));
|
|
287
|
+
const nameButton = within(headers[nameIndex]).getByRole('button');
|
|
288
|
+
|
|
289
|
+
nameButton.focus();
|
|
290
|
+
expect(headers[nameIndex].contains(document.activeElement)).toBe(true);
|
|
291
|
+
|
|
218
292
|
await user.keyboard('{ArrowRight}');
|
|
219
|
-
|
|
293
|
+
|
|
220
294
|
await waitFor(() => {
|
|
221
|
-
expect(
|
|
295
|
+
expect(getActiveHeaderLabel()).toContain('email');
|
|
222
296
|
});
|
|
223
297
|
|
|
224
|
-
// Press Left arrow to move back
|
|
225
298
|
await user.keyboard('{ArrowLeft}');
|
|
226
|
-
|
|
299
|
+
|
|
227
300
|
await waitFor(() => {
|
|
228
|
-
expect(
|
|
301
|
+
expect(getActiveHeaderLabel()).toContain('name');
|
|
229
302
|
});
|
|
230
303
|
});
|
|
231
304
|
|
|
232
|
-
it
|
|
233
|
-
const user = userEvent.setup();
|
|
234
|
-
|
|
235
|
-
render(
|
|
236
|
-
<TestWrapper>
|
|
237
|
-
<DataTable
|
|
238
|
-
data={testUsers}
|
|
239
|
-
columns={testColumns}
|
|
240
|
-
rbac={{ pageId: 'test-page' }}
|
|
241
|
-
features={defaultFeatures}
|
|
242
|
-
/>
|
|
243
|
-
</TestWrapper>
|
|
244
|
-
);
|
|
305
|
+
it('should navigate to first/last header with Home/End keys', async () => {
|
|
306
|
+
const user = userEvent.setup({ delay: 0 });
|
|
245
307
|
|
|
246
|
-
const nameHeader = screen.getByRole('columnheader', { name: /name/i });
|
|
247
|
-
const statusHeader = screen.getByRole('columnheader', { name: /status/i });
|
|
248
|
-
|
|
249
|
-
// Focus a middle header
|
|
250
|
-
const emailHeader = screen.getByRole('columnheader', { name: /email/i });
|
|
251
|
-
emailHeader.focus();
|
|
252
308
|
|
|
253
|
-
|
|
309
|
+
render(<KeyboardNavigationTestTable />);
|
|
310
|
+
|
|
311
|
+
const headers = screen.getAllByRole('columnheader');
|
|
312
|
+
const emailIndex = headers.findIndex(header => /email/i.test(header.textContent || ''));
|
|
313
|
+
const emailButton = within(headers[emailIndex]).getByRole('button');
|
|
314
|
+
emailButton.focus();
|
|
315
|
+
|
|
254
316
|
await user.keyboard('{Home}');
|
|
255
|
-
|
|
317
|
+
|
|
256
318
|
await waitFor(() => {
|
|
257
|
-
expect(
|
|
319
|
+
expect(getActiveHeaderLabel()).toContain(headers[0].textContent?.toLowerCase().trim() ?? '');
|
|
258
320
|
});
|
|
259
321
|
|
|
260
|
-
// Press End to go to last header
|
|
261
322
|
await user.keyboard('{End}');
|
|
262
|
-
|
|
323
|
+
|
|
263
324
|
await waitFor(() => {
|
|
264
|
-
|
|
325
|
+
const lastLabel = headers[headers.length - 1].textContent?.toLowerCase().trim() ?? '';
|
|
326
|
+
expect(getActiveHeaderLabel()).toContain(lastLabel);
|
|
265
327
|
});
|
|
266
328
|
});
|
|
267
329
|
});
|
|
268
330
|
|
|
269
331
|
describe('Cell Navigation', () => {
|
|
270
|
-
it
|
|
271
|
-
|
|
272
|
-
<TestWrapper>
|
|
273
|
-
<DataTable
|
|
274
|
-
data={testUsers}
|
|
275
|
-
columns={testColumns}
|
|
276
|
-
rbac={{ pageId: 'test-page' }}
|
|
277
|
-
features={defaultFeatures}
|
|
278
|
-
/>
|
|
279
|
-
</TestWrapper>
|
|
280
|
-
);
|
|
332
|
+
it('should implement roving tabindex for cells', async () => {
|
|
333
|
+
const user = userEvent.setup({ delay: 0 });
|
|
281
334
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
335
|
+
|
|
336
|
+
render(<KeyboardNavigationTestTable />);
|
|
337
|
+
|
|
338
|
+
const dataRows = screen.getAllByRole('row').slice(1);
|
|
339
|
+
const firstRowCells = within(dataRows[0]).getAllByRole('cell');
|
|
340
|
+
|
|
341
|
+
focusFirstFocusableInCell(firstRowCells[0]);
|
|
342
|
+
|
|
343
|
+
await waitFor(() => {
|
|
344
|
+
expect(getFocusedCellCoordinates()).toEqual({ rowIndex: 0, columnIndex: 0 });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await user.keyboard('{ArrowRight}');
|
|
348
|
+
|
|
349
|
+
await waitFor(() => {
|
|
350
|
+
expect(getFocusedCellCoordinates()).toEqual({ rowIndex: 0, columnIndex: 1 });
|
|
351
|
+
});
|
|
291
352
|
});
|
|
292
353
|
|
|
293
|
-
it
|
|
294
|
-
const user = userEvent.setup();
|
|
295
|
-
|
|
296
|
-
render(
|
|
297
|
-
<TestWrapper>
|
|
298
|
-
<DataTable
|
|
299
|
-
data={testUsers}
|
|
300
|
-
columns={testColumns}
|
|
301
|
-
rbac={{ pageId: 'test-page' }}
|
|
302
|
-
features={defaultFeatures}
|
|
303
|
-
/>
|
|
304
|
-
</TestWrapper>
|
|
305
|
-
);
|
|
354
|
+
it('should navigate between cells with arrow keys', async () => {
|
|
355
|
+
const user = userEvent.setup({ delay: 0 });
|
|
306
356
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
357
|
+
|
|
358
|
+
render(<KeyboardNavigationTestTable />);
|
|
359
|
+
|
|
360
|
+
const dataRows = screen.getAllByRole('row').slice(1);
|
|
361
|
+
const firstRowCells = within(dataRows[0]).getAllByRole('cell');
|
|
362
|
+
|
|
363
|
+
focusFirstFocusableInCell(firstRowCells[0]);
|
|
364
|
+
|
|
365
|
+
await user.keyboard('{ArrowRight}');
|
|
366
|
+
await waitFor(() => {
|
|
367
|
+
expect(getFocusedCellCoordinates()).toEqual({ rowIndex: 0, columnIndex: 1 });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await user.keyboard('{ArrowDown}');
|
|
371
|
+
await waitFor(() => {
|
|
372
|
+
expect(getFocusedCellCoordinates()).toEqual({ rowIndex: 1, columnIndex: 1 });
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await user.keyboard('{ArrowLeft}');
|
|
376
|
+
await waitFor(() => {
|
|
377
|
+
expect(getFocusedCellCoordinates()).toEqual({ rowIndex: 1, columnIndex: 0 });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
await user.keyboard('{ArrowUp}');
|
|
381
|
+
await waitFor(() => {
|
|
382
|
+
expect(getFocusedCellCoordinates()).toEqual({ rowIndex: 0, columnIndex: 0 });
|
|
383
|
+
});
|
|
328
384
|
});
|
|
329
385
|
|
|
330
|
-
it
|
|
331
|
-
const user = userEvent.setup();
|
|
332
|
-
|
|
333
|
-
render(
|
|
334
|
-
<TestWrapper>
|
|
335
|
-
<DataTable
|
|
336
|
-
data={testUsers}
|
|
337
|
-
columns={testColumns}
|
|
338
|
-
rbac={{ pageId: 'test-page' }}
|
|
339
|
-
features={defaultFeatures}
|
|
340
|
-
/>
|
|
341
|
-
</TestWrapper>
|
|
342
|
-
);
|
|
386
|
+
it('should navigate to row start/end with Home/End keys', async () => {
|
|
387
|
+
const user = userEvent.setup({ delay: 0 });
|
|
343
388
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
389
|
+
|
|
390
|
+
render(<KeyboardNavigationTestTable />);
|
|
391
|
+
|
|
392
|
+
const firstRowCells = within(screen.getAllByRole('row')[1]).getAllByRole('cell');
|
|
393
|
+
const middleIndex = Math.floor(firstRowCells.length / 2);
|
|
394
|
+
|
|
395
|
+
focusFirstFocusableInCell(firstRowCells[middleIndex]);
|
|
396
|
+
|
|
397
|
+
await user.keyboard('{Home}');
|
|
398
|
+
await waitFor(() => {
|
|
399
|
+
expect(getFocusedCellCoordinates()).toEqual({ rowIndex: 0, columnIndex: 0 });
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
await user.keyboard('{End}');
|
|
403
|
+
await waitFor(() => {
|
|
404
|
+
const lastColumn = firstRowCells.length - 1;
|
|
405
|
+
expect(getFocusedCellCoordinates()).toEqual({ rowIndex: 0, columnIndex: lastColumn });
|
|
406
|
+
});
|
|
360
407
|
});
|
|
361
408
|
|
|
362
|
-
it
|
|
363
|
-
const user = userEvent.setup();
|
|
364
|
-
|
|
365
|
-
render(
|
|
366
|
-
<TestWrapper>
|
|
367
|
-
<DataTable
|
|
368
|
-
data={testUsers}
|
|
369
|
-
columns={testColumns}
|
|
370
|
-
rbac={{ pageId: 'test-page' }}
|
|
371
|
-
features={defaultFeatures}
|
|
372
|
-
/>
|
|
373
|
-
</TestWrapper>
|
|
374
|
-
);
|
|
409
|
+
it('should navigate to table start/end with Ctrl+Home/End', async () => {
|
|
410
|
+
const user = userEvent.setup({ delay: 0 });
|
|
375
411
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
}
|
|
412
|
+
|
|
413
|
+
render(<KeyboardNavigationTestTable />);
|
|
414
|
+
|
|
415
|
+
const dataRows = screen.getAllByRole('row').slice(1);
|
|
416
|
+
const middleRow = dataRows[Math.floor(dataRows.length / 2)];
|
|
417
|
+
const middleRowCells = within(middleRow).getAllByRole('cell');
|
|
418
|
+
const middleCell = middleRowCells[Math.floor(middleRowCells.length / 2)];
|
|
419
|
+
|
|
420
|
+
focusFirstFocusableInCell(middleCell);
|
|
421
|
+
|
|
422
|
+
await user.keyboard('{Control>}{Home}{/Control}');
|
|
423
|
+
await waitFor(() => {
|
|
424
|
+
expect(getFocusedCellCoordinates()).toEqual({ rowIndex: 0, columnIndex: 0 });
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
await user.keyboard('{Control>}{End}{/Control}');
|
|
428
|
+
await waitFor(() => {
|
|
429
|
+
const lastRowIndex = dataRows.length - 1;
|
|
430
|
+
const lastColumnIndex = within(dataRows[lastRowIndex]).getAllByRole('cell').length - 1;
|
|
431
|
+
expect(getFocusedCellCoordinates()).toEqual({ rowIndex: lastRowIndex, columnIndex: lastColumnIndex });
|
|
432
|
+
});
|
|
392
433
|
});
|
|
393
434
|
});
|
|
394
435
|
|
|
395
436
|
describe('Focus Management', () => {
|
|
396
|
-
it
|
|
397
|
-
const user = userEvent.setup();
|
|
398
|
-
|
|
437
|
+
it('should restore focus when modals close', async () => {
|
|
438
|
+
const user = userEvent.setup({ delay: 0 });
|
|
439
|
+
|
|
440
|
+
|
|
399
441
|
render(
|
|
400
442
|
<TestWrapper>
|
|
401
443
|
<DataTable
|
|
@@ -410,28 +452,25 @@ describe('DataTable Keyboard Navigation', () => {
|
|
|
410
452
|
// Find and focus a cell
|
|
411
453
|
const cells = screen.getAllByRole('cell');
|
|
412
454
|
const firstCell = cells[0];
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
}
|
|
455
|
+
|
|
456
|
+
focusFirstFocusableInCell(firstCell as HTMLElement);
|
|
457
|
+
|
|
458
|
+
await waitFor(() => {
|
|
459
|
+
expect(firstCell.contains(document.activeElement)).toBe(true);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const importButton = await screen.findByRole('button', { name: /import/i });
|
|
463
|
+
await user.click(importButton);
|
|
464
|
+
|
|
465
|
+
// Confirm the modal is open
|
|
466
|
+
await screen.findByRole('dialog');
|
|
467
|
+
|
|
468
|
+
// Close modal via Escape key
|
|
469
|
+
await user.keyboard('{Escape}');
|
|
470
|
+
|
|
471
|
+
await waitFor(() => {
|
|
472
|
+
expect(importButton).toHaveFocus();
|
|
473
|
+
});
|
|
435
474
|
});
|
|
436
475
|
|
|
437
476
|
it('should not show focus warnings in console', () => {
|
|
@@ -558,9 +597,10 @@ describe('DataTable Keyboard Navigation', () => {
|
|
|
558
597
|
});
|
|
559
598
|
|
|
560
599
|
describe('Integration Tests', () => {
|
|
561
|
-
it
|
|
562
|
-
const user = userEvent.setup();
|
|
563
|
-
|
|
600
|
+
it('should work end-to-end with sorting and navigation', async () => {
|
|
601
|
+
const user = userEvent.setup({ delay: 0 });
|
|
602
|
+
|
|
603
|
+
|
|
564
604
|
render(
|
|
565
605
|
<TestWrapper>
|
|
566
606
|
<DataTable
|
|
@@ -573,43 +613,62 @@ describe('DataTable Keyboard Navigation', () => {
|
|
|
573
613
|
);
|
|
574
614
|
|
|
575
615
|
// Navigate to name header
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
616
|
+
const headers = screen.getAllByRole('columnheader');
|
|
617
|
+
const nameIndex = headers.findIndex(header => /name/i.test(header.textContent || ''));
|
|
618
|
+
const nameButton = within(headers[nameIndex]).getByRole('button');
|
|
619
|
+
nameButton.focus();
|
|
620
|
+
|
|
579
621
|
// Sort by name
|
|
580
622
|
await user.keyboard('{Enter}');
|
|
581
|
-
|
|
623
|
+
|
|
582
624
|
await waitFor(() => {
|
|
583
|
-
expect(
|
|
625
|
+
expect(headers[nameIndex]).toHaveAttribute('aria-sort', 'ascending');
|
|
584
626
|
});
|
|
585
|
-
|
|
586
|
-
//
|
|
627
|
+
|
|
628
|
+
// Arrow navigation keeps focus within the current header group in the real table
|
|
587
629
|
await user.keyboard('{ArrowRight}');
|
|
588
|
-
|
|
589
|
-
const emailHeader = screen.getByRole('columnheader', { name: /email/i });
|
|
630
|
+
|
|
590
631
|
await waitFor(() => {
|
|
591
|
-
|
|
632
|
+
const activeHeader = headers.find(header => header.contains(document.activeElement));
|
|
633
|
+
expect(activeHeader).toBe(headers[nameIndex]);
|
|
592
634
|
});
|
|
593
|
-
|
|
635
|
+
|
|
636
|
+
// Tabbing reaches the next sortable header button so we can change sort order
|
|
637
|
+
await user.tab();
|
|
638
|
+
|
|
639
|
+
const emailIndex = headers.findIndex(header => /email/i.test(header.textContent || ''));
|
|
640
|
+
const emailButton = within(headers[emailIndex]).getByRole('button');
|
|
641
|
+
expect(emailButton).toHaveFocus();
|
|
642
|
+
|
|
594
643
|
// Sort by email
|
|
595
644
|
await user.keyboard(' ');
|
|
596
|
-
|
|
645
|
+
|
|
597
646
|
await waitFor(() => {
|
|
598
|
-
expect(
|
|
647
|
+
expect(headers[emailIndex]).toHaveAttribute('aria-sort', 'ascending');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// Verify the dataset reflects the applied sorts
|
|
651
|
+
const rows = screen.getAllByRole('row').slice(1);
|
|
652
|
+
const firstRowCells = within(rows[0]).getAllByRole('cell');
|
|
653
|
+
|
|
654
|
+
await waitFor(() => {
|
|
655
|
+
expect(firstRowCells[1]).toHaveTextContent(/alice brown/i);
|
|
656
|
+
expect(firstRowCells[2]).toHaveTextContent(/alice@example.com/i);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// Focus the first data cell to confirm it remains keyboard accessible
|
|
660
|
+
const firstDataCell = firstRowCells[1];
|
|
661
|
+
focusFirstFocusableInCell(firstDataCell);
|
|
662
|
+
|
|
663
|
+
await waitFor(() => {
|
|
664
|
+
expect(firstDataCell.contains(document.activeElement)).toBe(true);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
await user.tab();
|
|
668
|
+
|
|
669
|
+
await waitFor(() => {
|
|
670
|
+
expect(firstDataCell.contains(document.activeElement)).toBe(false);
|
|
599
671
|
});
|
|
600
|
-
|
|
601
|
-
// Navigate to first cell
|
|
602
|
-
const cells = screen.getAllByRole('cell');
|
|
603
|
-
if (cells[0]) {
|
|
604
|
-
cells[0].focus();
|
|
605
|
-
|
|
606
|
-
// Navigate around cells
|
|
607
|
-
await user.keyboard('{ArrowRight}');
|
|
608
|
-
await user.keyboard('{ArrowDown}');
|
|
609
|
-
await user.keyboard('{Home}');
|
|
610
|
-
|
|
611
|
-
// Should not cause any errors or warnings
|
|
612
|
-
}
|
|
613
672
|
});
|
|
614
673
|
});
|
|
615
674
|
});
|
|
@@ -361,18 +361,40 @@ function DataTableInternal<TData extends DataRecord>({
|
|
|
361
361
|
}
|
|
362
362
|
);
|
|
363
363
|
|
|
364
|
+
const lastFocusedElementRef = useRef<HTMLElement | null>(null);
|
|
365
|
+
const wasImportModalOpenRef = useRef(false);
|
|
366
|
+
|
|
364
367
|
// Store focus when modals open, restore when they close
|
|
365
368
|
useEffect(() => {
|
|
366
369
|
if (state.showImportModal) {
|
|
370
|
+
wasImportModalOpenRef.current = true;
|
|
367
371
|
keyboardNavigation.storeFocus();
|
|
372
|
+
if (document.activeElement instanceof HTMLElement) {
|
|
373
|
+
lastFocusedElementRef.current = document.activeElement;
|
|
374
|
+
}
|
|
368
375
|
}
|
|
369
376
|
}, [state.showImportModal, keyboardNavigation]);
|
|
370
377
|
|
|
371
378
|
useEffect(() => {
|
|
372
379
|
if (!state.showImportModal) {
|
|
380
|
+
if (!wasImportModalOpenRef.current) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
wasImportModalOpenRef.current = false;
|
|
373
384
|
// Restore focus after modal closes
|
|
374
385
|
setTimeout(() => {
|
|
375
|
-
|
|
386
|
+
const storedElement = lastFocusedElementRef.current;
|
|
387
|
+
lastFocusedElementRef.current = null;
|
|
388
|
+
|
|
389
|
+
const elementToRestore = storedElement?.isConnected
|
|
390
|
+
? storedElement
|
|
391
|
+
: document.querySelector<HTMLElement>('[data-restore-target="datatable-import-button"]');
|
|
392
|
+
|
|
393
|
+
if (elementToRestore && typeof elementToRestore.focus === 'function') {
|
|
394
|
+
elementToRestore.focus();
|
|
395
|
+
} else {
|
|
396
|
+
keyboardNavigation.restoreFocus();
|
|
397
|
+
}
|
|
376
398
|
}, 100); // Small delay to ensure modal is fully closed
|
|
377
399
|
}
|
|
378
400
|
}, [state.showImportModal, keyboardNavigation]);
|
|
@@ -993,7 +1015,12 @@ function DataTableInternal<TData extends DataRecord>({
|
|
|
993
1015
|
stateActions.setColumnVisibility({ ...state.columnVisibility, [columnId]: visible });
|
|
994
1016
|
}}
|
|
995
1017
|
onCreateRow={secureFeatures.creation && secureHandlers.onCreateRow ? () => stateActions.setCreating(true) : undefined}
|
|
996
|
-
onImportClick={() =>
|
|
1018
|
+
onImportClick={() => {
|
|
1019
|
+
if (document.activeElement instanceof HTMLElement) {
|
|
1020
|
+
lastFocusedElementRef.current = document.activeElement;
|
|
1021
|
+
}
|
|
1022
|
+
stateActions.setImportModal(true);
|
|
1023
|
+
}}
|
|
997
1024
|
onExport={secureHandlers.onExport || (async () => {
|
|
998
1025
|
try {
|
|
999
1026
|
// Automatic export: exports exactly what's shown in the table
|
|
@@ -224,9 +224,10 @@ export function DataTableToolbar<TData extends DataRecord>({
|
|
|
224
224
|
|
|
225
225
|
{/* Import - ONLY if user has import permission */}
|
|
226
226
|
{features.import && permissions.canImport.can && (
|
|
227
|
-
<Button
|
|
228
|
-
variant="outline"
|
|
227
|
+
<Button
|
|
228
|
+
variant="outline"
|
|
229
229
|
onClick={onImportClick}
|
|
230
|
+
data-restore-target="datatable-import-button"
|
|
230
231
|
>
|
|
231
232
|
<Upload className="h-4 w-4 mr-2 flex-shrink-0" />
|
|
232
233
|
<span className="truncate">Import</span>
|