@jmruthers/pace-core 0.5.184 → 0.5.185

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (306) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +60 -1
  3. package/core-usage-manifest.json +312 -0
  4. package/dist/{DataTable-QAB34V6K.js → DataTable-IX2NBUTP.js} +6 -6
  5. package/dist/{DataTable-Bz8ffqyA.d.ts → DataTable-Z9NLVJh0.d.ts} +1 -1
  6. package/dist/{index-Bl--n7-T.d.ts → PublicPageProvider-BABf6JCh.d.ts} +21 -10
  7. package/dist/{UnifiedAuthProvider-7F6T4B6K.js → UnifiedAuthProvider-A4BCQRJY.js} +4 -2
  8. package/dist/{UnifiedAuthProvider-F86d7dSi.d.ts → UnifiedAuthProvider-BG0AL5eE.d.ts} +2 -1
  9. package/dist/{api-ROMBCNKU.js → api-BMFCXVQX.js} +2 -2
  10. package/dist/{chunk-RA3JUFMW.js → chunk-445GEP27.js} +154 -4
  11. package/dist/{chunk-RA3JUFMW.js.map → chunk-445GEP27.js.map} +1 -1
  12. package/dist/{chunk-CSOFYHAG.js → chunk-AISXLWGZ.js} +374 -60
  13. package/dist/chunk-AISXLWGZ.js.map +1 -0
  14. package/dist/{chunk-FUEYYMX5.js → chunk-FXFJRTKI.js} +24 -3
  15. package/dist/chunk-FXFJRTKI.js.map +1 -0
  16. package/dist/{chunk-NQPMQGS2.js → chunk-HC67NW5K.js} +379 -359
  17. package/dist/chunk-HC67NW5K.js.map +1 -0
  18. package/dist/chunk-HESYZWZW.js +388 -0
  19. package/dist/chunk-HESYZWZW.js.map +1 -0
  20. package/dist/{chunk-QUVSNGIP.js → chunk-HGPQUCBC.js} +34 -9
  21. package/dist/{chunk-QUVSNGIP.js.map → chunk-HGPQUCBC.js.map} +1 -1
  22. package/dist/{chunk-UHNYIBXL.js → chunk-IXSNYUCT.js} +1 -1
  23. package/dist/chunk-IXSNYUCT.js.map +1 -0
  24. package/dist/{chunk-MI7HBHN3.js → chunk-MX3EIJGQ.js} +4 -3
  25. package/dist/{chunk-MI7HBHN3.js.map → chunk-MX3EIJGQ.js.map} +1 -1
  26. package/dist/{chunk-PWAHJW4G.js → chunk-OKI34GZD.js} +86 -33
  27. package/dist/chunk-OKI34GZD.js.map +1 -0
  28. package/dist/{chunk-W22JP75J.js → chunk-STTZQK2I.js} +3 -3
  29. package/dist/chunk-THRPYOFK.js +215 -0
  30. package/dist/chunk-THRPYOFK.js.map +1 -0
  31. package/dist/{chunk-M7W4CP3M.js → chunk-U6WNSFX5.js} +2 -1
  32. package/dist/chunk-U6WNSFX5.js.map +1 -0
  33. package/dist/{chunk-QCDXODCA.js → chunk-XAUHJD3L.js} +2 -2
  34. package/dist/components.d.ts +182 -6
  35. package/dist/components.js +157 -11
  36. package/dist/components.js.map +1 -1
  37. package/dist/eslint-rules/pace-core-compliance.cjs +406 -0
  38. package/dist/{file-reference-D06mEEWW.d.ts → file-reference-BjR39ktt.d.ts} +7 -1
  39. package/dist/hooks.d.ts +7 -14
  40. package/dist/hooks.js +10 -22
  41. package/dist/hooks.js.map +1 -1
  42. package/dist/index.d.ts +11 -11
  43. package/dist/index.js +79 -16
  44. package/dist/index.js.map +1 -1
  45. package/dist/providers.d.ts +1 -1
  46. package/dist/providers.js +3 -1
  47. package/dist/rbac/index.d.ts +205 -14
  48. package/dist/rbac/index.js +28 -6
  49. package/dist/timezone-_pgH8qrY.d.ts +530 -0
  50. package/dist/{types-_x1f4QBF.d.ts → types-DUyCRSTj.d.ts} +1 -1
  51. package/dist/types.d.ts +1 -1
  52. package/dist/types.js +1 -1
  53. package/dist/{usePublicRouteParams-JJczomYq.d.ts → usePublicRouteParams-CvnC3d-e.d.ts} +113 -2
  54. package/dist/utils.d.ts +109 -151
  55. package/dist/utils.js +128 -138
  56. package/dist/utils.js.map +1 -1
  57. package/docs/api/README.md +60 -1
  58. package/docs/api/classes/ColumnFactory.md +1 -1
  59. package/docs/api/classes/ErrorBoundary.md +1 -1
  60. package/docs/api/classes/InvalidScopeError.md +1 -1
  61. package/docs/api/classes/Logger.md +178 -0
  62. package/docs/api/classes/MissingUserContextError.md +1 -1
  63. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  64. package/docs/api/classes/PermissionDeniedError.md +1 -1
  65. package/docs/api/classes/RBACAuditManager.md +2 -2
  66. package/docs/api/classes/RBACCache.md +1 -1
  67. package/docs/api/classes/RBACEngine.md +2 -2
  68. package/docs/api/classes/RBACError.md +1 -1
  69. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  70. package/docs/api/classes/SecureSupabaseClient.md +5 -5
  71. package/docs/api/classes/StorageUtils.md +1 -1
  72. package/docs/api/enums/FileCategory.md +1 -1
  73. package/docs/api/enums/LogLevel.md +54 -0
  74. package/docs/api/enums/RBACErrorCode.md +1 -1
  75. package/docs/api/enums/RPCFunction.md +1 -1
  76. package/docs/api/interfaces/AggregateConfig.md +1 -1
  77. package/docs/api/interfaces/BadgeProps.md +1 -1
  78. package/docs/api/interfaces/ButtonProps.md +1 -1
  79. package/docs/api/interfaces/CalendarProps.md +18 -2
  80. package/docs/api/interfaces/CardProps.md +1 -1
  81. package/docs/api/interfaces/ColorPalette.md +1 -1
  82. package/docs/api/interfaces/ColorShade.md +1 -1
  83. package/docs/api/interfaces/ComplianceResult.md +30 -0
  84. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  85. package/docs/api/interfaces/DataRecord.md +1 -1
  86. package/docs/api/interfaces/DataTableAction.md +1 -1
  87. package/docs/api/interfaces/DataTableColumn.md +1 -1
  88. package/docs/api/interfaces/DataTableProps.md +1 -1
  89. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  90. package/docs/api/interfaces/DatabaseComplianceResult.md +85 -0
  91. package/docs/api/interfaces/DatabaseIssue.md +41 -0
  92. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  93. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  94. package/docs/api/interfaces/EventAppRoleData.md +6 -6
  95. package/docs/api/interfaces/ExportColumn.md +1 -1
  96. package/docs/api/interfaces/ExportOptions.md +1 -1
  97. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  98. package/docs/api/interfaces/FileMetadata.md +1 -1
  99. package/docs/api/interfaces/FileReference.md +1 -1
  100. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  101. package/docs/api/interfaces/FileUploadOptions.md +24 -8
  102. package/docs/api/interfaces/FileUploadProps.md +24 -13
  103. package/docs/api/interfaces/FooterProps.md +1 -1
  104. package/docs/api/interfaces/FormFieldProps.md +1 -1
  105. package/docs/api/interfaces/FormProps.md +1 -1
  106. package/docs/api/interfaces/GrantEventAppRoleParams.md +9 -9
  107. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  108. package/docs/api/interfaces/InputProps.md +1 -1
  109. package/docs/api/interfaces/LabelProps.md +1 -1
  110. package/docs/api/interfaces/LoggerConfig.md +62 -0
  111. package/docs/api/interfaces/LoginFormProps.md +1 -1
  112. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  113. package/docs/api/interfaces/NavigationContextType.md +1 -1
  114. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  115. package/docs/api/interfaces/NavigationItem.md +1 -1
  116. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  117. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  118. package/docs/api/interfaces/Organisation.md +1 -1
  119. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  120. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  121. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  122. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  123. package/docs/api/interfaces/PaceAppLayoutProps.md +36 -23
  124. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  125. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  126. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  127. package/docs/api/interfaces/PagePermissionGuardProps.md +11 -11
  128. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  129. package/docs/api/interfaces/PaletteData.md +1 -1
  130. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  131. package/docs/api/interfaces/ProgressProps.md +1 -1
  132. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  133. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  134. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  135. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  136. package/docs/api/interfaces/QuickFix.md +52 -0
  137. package/docs/api/interfaces/RBACAccessValidateParams.md +1 -1
  138. package/docs/api/interfaces/RBACAccessValidateResult.md +1 -1
  139. package/docs/api/interfaces/RBACAuditLogParams.md +1 -1
  140. package/docs/api/interfaces/RBACAuditLogResult.md +1 -1
  141. package/docs/api/interfaces/RBACConfig.md +4 -4
  142. package/docs/api/interfaces/RBACContext.md +1 -1
  143. package/docs/api/interfaces/RBACLogger.md +1 -1
  144. package/docs/api/interfaces/RBACPageAccessCheckParams.md +1 -1
  145. package/docs/api/interfaces/RBACPermissionCheckParams.md +1 -1
  146. package/docs/api/interfaces/RBACPermissionCheckResult.md +1 -1
  147. package/docs/api/interfaces/RBACPermissionsGetParams.md +1 -1
  148. package/docs/api/interfaces/RBACPermissionsGetResult.md +1 -1
  149. package/docs/api/interfaces/RBACResult.md +1 -1
  150. package/docs/api/interfaces/RBACRoleGrantParams.md +1 -1
  151. package/docs/api/interfaces/RBACRoleGrantResult.md +1 -1
  152. package/docs/api/interfaces/RBACRoleRevokeParams.md +1 -1
  153. package/docs/api/interfaces/RBACRoleRevokeResult.md +1 -1
  154. package/docs/api/interfaces/RBACRoleValidateParams.md +1 -1
  155. package/docs/api/interfaces/RBACRoleValidateResult.md +1 -1
  156. package/docs/api/interfaces/RBACRolesListParams.md +1 -1
  157. package/docs/api/interfaces/RBACRolesListResult.md +1 -1
  158. package/docs/api/interfaces/RBACSessionTrackParams.md +1 -1
  159. package/docs/api/interfaces/RBACSessionTrackResult.md +1 -1
  160. package/docs/api/interfaces/ResourcePermissions.md +1 -1
  161. package/docs/api/interfaces/RevokeEventAppRoleParams.md +7 -7
  162. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  163. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  164. package/docs/api/interfaces/RoleManagementResult.md +5 -5
  165. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  166. package/docs/api/interfaces/RouteConfig.md +1 -1
  167. package/docs/api/interfaces/RuntimeComplianceResult.md +55 -0
  168. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  169. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  170. package/docs/api/interfaces/SessionRestorationLoaderProps.md +1 -1
  171. package/docs/api/interfaces/SetupIssue.md +41 -0
  172. package/docs/api/interfaces/StorageConfig.md +1 -1
  173. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  174. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  175. package/docs/api/interfaces/StorageListOptions.md +1 -1
  176. package/docs/api/interfaces/StorageListResult.md +1 -1
  177. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  178. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  179. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  180. package/docs/api/interfaces/StyleImport.md +1 -1
  181. package/docs/api/interfaces/SwitchProps.md +1 -1
  182. package/docs/api/interfaces/TabsContentProps.md +1 -1
  183. package/docs/api/interfaces/TabsListProps.md +1 -1
  184. package/docs/api/interfaces/TabsProps.md +1 -1
  185. package/docs/api/interfaces/TabsTriggerProps.md +1 -1
  186. package/docs/api/interfaces/TextareaProps.md +1 -1
  187. package/docs/api/interfaces/ToastActionElement.md +1 -1
  188. package/docs/api/interfaces/ToastProps.md +1 -1
  189. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  190. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  191. package/docs/api/interfaces/UseFormDialogOptions.md +62 -0
  192. package/docs/api/interfaces/UseFormDialogReturn.md +117 -0
  193. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  194. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  195. package/docs/api/interfaces/UsePublicEventLogoOptions.md +2 -2
  196. package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
  197. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  198. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  199. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +2 -2
  200. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  201. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  202. package/docs/api/interfaces/UseResolvedScopeOptions.md +2 -2
  203. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  204. package/docs/api/interfaces/UseResourcePermissionsOptions.md +1 -1
  205. package/docs/api/interfaces/UserEventAccess.md +1 -1
  206. package/docs/api/interfaces/UserMenuProps.md +1 -1
  207. package/docs/api/interfaces/UserProfile.md +1 -1
  208. package/docs/api/modules.md +738 -42
  209. package/docs/api-reference/hooks.md +111 -0
  210. package/docs/api-reference/rpc-functions.md +1 -1
  211. package/docs/api-reference/utilities.md +184 -0
  212. package/docs/getting-started/installation-guide.md +75 -16
  213. package/docs/getting-started/quick-start.md +61 -11
  214. package/docs/implementation-guides/authentication.md +88 -12
  215. package/docs/implementation-guides/file-reference-system.md +2 -1
  216. package/docs/implementation-guides/file-upload-storage.md +21 -0
  217. package/docs/rbac/README.md +1 -0
  218. package/docs/rbac/compliance/compliance-guide.md +544 -0
  219. package/docs/rbac/getting-started.md +158 -33
  220. package/docs/standards/pace-core-compliance.md +432 -0
  221. package/eslint-config-pace-core.cjs +93 -0
  222. package/package.json +15 -3
  223. package/scripts/analyze-bundle.js +232 -0
  224. package/scripts/build-css.js +56 -0
  225. package/scripts/build-docs-incremental.js +1015 -0
  226. package/scripts/check-pace-core-compliance.cjs +2353 -0
  227. package/scripts/generate-docs.js +157 -0
  228. package/scripts/setup-build-cache.js +73 -0
  229. package/scripts/utils/command-runner.js +131 -0
  230. package/scripts/utils/env.js +33 -0
  231. package/scripts/utils/index.js +10 -0
  232. package/scripts/utils/logger.js +88 -0
  233. package/scripts/utils/path-helpers.js +37 -0
  234. package/scripts/validate-formats.js +133 -0
  235. package/scripts/validate-master.js +155 -0
  236. package/scripts/validate-pre-publish.js +140 -0
  237. package/scripts/validate-theme.js +142 -0
  238. package/src/components/Calendar/Calendar.tsx +8 -1
  239. package/src/components/Card/Card.tsx +47 -8
  240. package/src/components/DatePickerWithTimezone/DatePickerWithTimezone.test.tsx +314 -0
  241. package/src/components/DatePickerWithTimezone/DatePickerWithTimezone.tsx +126 -0
  242. package/src/components/DatePickerWithTimezone/README.md +135 -0
  243. package/src/components/DatePickerWithTimezone/index.ts +10 -0
  244. package/src/components/DateTimeField/DateTimeField.test.tsx +358 -0
  245. package/src/components/DateTimeField/DateTimeField.tsx +232 -0
  246. package/src/components/DateTimeField/README.md +148 -0
  247. package/src/components/DateTimeField/index.ts +10 -0
  248. package/src/components/FileUpload/FileUpload.tsx +3 -0
  249. package/src/components/Header/Header.test.tsx +47 -18
  250. package/src/components/Header/Header.tsx +24 -6
  251. package/src/components/PaceAppLayout/PaceAppLayout.tsx +29 -20
  252. package/src/components/PaceAppLayout/README.md +9 -0
  253. package/src/components/ProtectedRoute/ProtectedRoute.test.tsx +37 -8
  254. package/src/components/ProtectedRoute/ProtectedRoute.tsx +12 -4
  255. package/src/components/index.ts +8 -0
  256. package/src/eslint-rules/pace-core-compliance.cjs +406 -0
  257. package/src/eslint-rules/pace-core-compliance.js +640 -0
  258. package/src/hooks/__tests__/useFormDialog.test.ts +478 -0
  259. package/src/hooks/index.ts +2 -0
  260. package/src/hooks/useFileReference.test.ts +1 -0
  261. package/src/hooks/useFormDialog.ts +147 -0
  262. package/src/index.ts +27 -0
  263. package/src/providers/services/OrganisationServiceProvider.tsx +6 -5
  264. package/src/providers/services/UnifiedAuthProvider.tsx +24 -3
  265. package/src/rbac/__tests__/scenarios.user-role.test.tsx +3 -0
  266. package/src/rbac/compliance/database-validator.ts +165 -0
  267. package/src/rbac/compliance/index.ts +38 -0
  268. package/src/rbac/compliance/quick-fix-suggestions.ts +209 -0
  269. package/src/rbac/compliance/runtime-compliance.ts +77 -0
  270. package/src/rbac/compliance/setup-validator.ts +131 -0
  271. package/src/rbac/components/PagePermissionGuard.tsx +8 -64
  272. package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +35 -21
  273. package/src/rbac/docs/event-based-apps.md +285 -0
  274. package/src/rbac/errors.ts +11 -0
  275. package/src/rbac/hooks/useRoleManagement.ts +292 -12
  276. package/src/rbac/index.ts +30 -0
  277. package/src/services/OrganisationService.ts +4 -0
  278. package/src/types/file-reference.ts +6 -0
  279. package/src/utils/__tests__/timezone.test.ts +345 -0
  280. package/src/utils/file-reference/__tests__/file-reference.test.ts +2 -0
  281. package/src/utils/file-reference/index.ts +1 -0
  282. package/src/utils/formatting/formatDateTimeTimezone.test.ts +167 -0
  283. package/src/utils/formatting/formatting.ts +179 -0
  284. package/src/utils/index.ts +27 -1
  285. package/src/utils/location/index.ts +16 -0
  286. package/src/utils/location/location.test.ts +286 -0
  287. package/src/utils/location/location.ts +175 -0
  288. package/src/utils/timezone/index.ts +17 -0
  289. package/src/utils/timezone/timezone.test.ts +349 -0
  290. package/src/utils/timezone/timezone.ts +281 -0
  291. package/dist/chunk-CSOFYHAG.js.map +0 -1
  292. package/dist/chunk-FUEYYMX5.js.map +0 -1
  293. package/dist/chunk-HKIT6O7W.js +0 -198
  294. package/dist/chunk-HKIT6O7W.js.map +0 -1
  295. package/dist/chunk-KUEN3HFB.js +0 -94
  296. package/dist/chunk-KUEN3HFB.js.map +0 -1
  297. package/dist/chunk-M7W4CP3M.js.map +0 -1
  298. package/dist/chunk-NQPMQGS2.js.map +0 -1
  299. package/dist/chunk-PWAHJW4G.js.map +0 -1
  300. package/dist/chunk-UHNYIBXL.js.map +0 -1
  301. package/dist/formatting-5wETwiGF.d.ts +0 -162
  302. /package/dist/{DataTable-QAB34V6K.js.map → DataTable-IX2NBUTP.js.map} +0 -0
  303. /package/dist/{UnifiedAuthProvider-7F6T4B6K.js.map → UnifiedAuthProvider-A4BCQRJY.js.map} +0 -0
  304. /package/dist/{api-ROMBCNKU.js.map → api-BMFCXVQX.js.map} +0 -0
  305. /package/dist/{chunk-W22JP75J.js.map → chunk-STTZQK2I.js.map} +0 -0
  306. /package/dist/{chunk-QCDXODCA.js.map → chunk-XAUHJD3L.js.map} +0 -0
@@ -0,0 +1,358 @@
1
+ /**
2
+ * @file DateTimeField Component Tests
3
+ * @package @jmruthers/pace-core
4
+ * @module Components/DateTimeField/__tests__
5
+ * @since 0.1.0
6
+ *
7
+ * Comprehensive test suite for DateTimeField component.
8
+ * Tests cover all major functionality, edge cases, and accessibility.
9
+ */
10
+
11
+ import React from 'react';
12
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
13
+ import userEvent from '@testing-library/user-event';
14
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
15
+ import { DateTimeField } from './DateTimeField';
16
+
17
+ // Mock timezone utilities
18
+ vi.mock('../../utils/timezone', () => ({
19
+ toZonedTime: vi.fn((date: Date, tz: string) => {
20
+ // Simple mock: return date as-is for testing
21
+ return date;
22
+ }),
23
+ fromZonedTime: vi.fn((date: Date, tz: string) => {
24
+ // Simple mock: return date as-is for testing
25
+ return date;
26
+ }),
27
+ getUserTimeZone: vi.fn(() => 'America/New_York')
28
+ }));
29
+
30
+ describe('DateTimeField Component', () => {
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ });
34
+
35
+ describe('Rendering', () => {
36
+ it('renders with default props', () => {
37
+ const onChange = vi.fn();
38
+ render(<DateTimeField label="Start Time" value={undefined} onChange={onChange} />);
39
+
40
+ expect(screen.getByText('Start Time')).toBeInTheDocument();
41
+ expect(screen.getByLabelText(/Start Time/)).toBeInTheDocument();
42
+ });
43
+
44
+ it('renders with required indicator', () => {
45
+ const onChange = vi.fn();
46
+ render(<DateTimeField label="Start Time" value={undefined} onChange={onChange} required />);
47
+
48
+ const label = screen.getByText('Start Time');
49
+ expect(label).toBeInTheDocument();
50
+ // Required indicator should be present
51
+ expect(screen.getByLabelText(/required/i)).toBeInTheDocument();
52
+ });
53
+
54
+ it('renders with helper text', () => {
55
+ const onChange = vi.fn();
56
+ render(
57
+ <DateTimeField
58
+ label="Start Time"
59
+ value={undefined}
60
+ onChange={onChange}
61
+ helperText="Select a start time"
62
+ />
63
+ );
64
+
65
+ expect(screen.getByText('Select a start time')).toBeInTheDocument();
66
+ });
67
+
68
+ it('renders with error message', () => {
69
+ const onChange = vi.fn();
70
+ render(
71
+ <DateTimeField
72
+ label="Start Time"
73
+ value={undefined}
74
+ onChange={onChange}
75
+ error="Invalid date"
76
+ />
77
+ );
78
+
79
+ expect(screen.getByText('Invalid date')).toBeInTheDocument();
80
+ expect(screen.getByRole('alert')).toBeInTheDocument();
81
+ });
82
+
83
+ it('renders with custom id', () => {
84
+ const onChange = vi.fn();
85
+ render(
86
+ <DateTimeField
87
+ id="custom-id"
88
+ label="Start Time"
89
+ value={undefined}
90
+ onChange={onChange}
91
+ />
92
+ );
93
+
94
+ expect(screen.getByLabelText(/Start Time/)).toHaveAttribute('id', 'custom-id');
95
+ });
96
+ });
97
+
98
+ describe('Value Display and Conversion', () => {
99
+ it('displays ISO string value correctly', () => {
100
+ const onChange = vi.fn();
101
+ const value = '2024-01-15T10:00:00Z';
102
+ render(<DateTimeField label="Start Time" value={value} onChange={onChange} />);
103
+
104
+ const input = screen.getByLabelText(/Start Time/) as HTMLInputElement;
105
+ expect(input.value).toBeTruthy();
106
+ });
107
+
108
+ it('displays Date object value correctly', () => {
109
+ const onChange = vi.fn();
110
+ const value = new Date('2024-01-15T10:00:00Z');
111
+ render(<DateTimeField label="Start Time" value={value} onChange={onChange} />);
112
+
113
+ const input = screen.getByLabelText(/Start Time/) as HTMLInputElement;
114
+ expect(input.value).toBeTruthy();
115
+ });
116
+
117
+ it('handles undefined value', () => {
118
+ const onChange = vi.fn();
119
+ render(<DateTimeField label="Start Time" value={undefined} onChange={onChange} />);
120
+
121
+ const input = screen.getByLabelText(/Start Time/) as HTMLInputElement;
122
+ expect(input.value).toBe('');
123
+ });
124
+
125
+ it('displays timezone information when not UTC', () => {
126
+ const onChange = vi.fn();
127
+ render(
128
+ <DateTimeField
129
+ label="Start Time"
130
+ value={undefined}
131
+ onChange={onChange}
132
+ timezone="Europe/London"
133
+ />
134
+ );
135
+
136
+ expect(screen.getByText('Europe/London')).toBeInTheDocument();
137
+ });
138
+
139
+ it('displays "Local" when timezone matches user timezone', () => {
140
+ const onChange = vi.fn();
141
+ render(
142
+ <DateTimeField
143
+ label="Start Time"
144
+ value={undefined}
145
+ onChange={onChange}
146
+ timezone="America/New_York"
147
+ />
148
+ );
149
+
150
+ expect(screen.getByText('Local')).toBeInTheDocument();
151
+ });
152
+
153
+ it('does not display timezone for UTC', () => {
154
+ const onChange = vi.fn();
155
+ const { container } = render(
156
+ <DateTimeField
157
+ label="Start Time"
158
+ value={undefined}
159
+ onChange={onChange}
160
+ timezone="UTC"
161
+ />
162
+ );
163
+
164
+ const timezoneDisplay = container.querySelector('.text-muted-foreground');
165
+ expect(timezoneDisplay).not.toBeInTheDocument();
166
+ });
167
+ });
168
+
169
+ describe('onChange Behavior', () => {
170
+ it('calls onChange with ISO string when returnAsDate is false', async () => {
171
+ const onChange = vi.fn();
172
+ const user = userEvent.setup();
173
+
174
+ render(<DateTimeField label="Start Time" value={undefined} onChange={onChange} />);
175
+
176
+ const input = screen.getByLabelText(/Start Time/) as HTMLInputElement;
177
+ await user.type(input, '2024-01-15T10:00');
178
+
179
+ await waitFor(() => {
180
+ expect(onChange).toHaveBeenCalled();
181
+ });
182
+ });
183
+
184
+ it('calls onChange with Date object when returnAsDate is true', async () => {
185
+ const onChange = vi.fn();
186
+ const user = userEvent.setup();
187
+
188
+ render(
189
+ <DateTimeField
190
+ label="Start Time"
191
+ value={undefined}
192
+ onChange={onChange}
193
+ returnAsDate
194
+ />
195
+ );
196
+
197
+ const input = screen.getByLabelText(/Start Time/) as HTMLInputElement;
198
+ await user.type(input, '2024-01-15T10:00');
199
+
200
+ await waitFor(() => {
201
+ expect(onChange).toHaveBeenCalled();
202
+ const callArg = onChange.mock.calls[onChange.mock.calls.length - 1][0];
203
+ expect(callArg).toBeInstanceOf(Date);
204
+ });
205
+ });
206
+
207
+ it('calls onChange with undefined when input is cleared', async () => {
208
+ const onChange = vi.fn();
209
+ const user = userEvent.setup();
210
+
211
+ // Set an initial value first
212
+ const { rerender } = render(
213
+ <DateTimeField label="Start Time" value="2024-01-15T10:00:00Z" onChange={onChange} />
214
+ );
215
+
216
+ const input = screen.getByLabelText(/Start Time/) as HTMLInputElement;
217
+ await user.clear(input);
218
+
219
+ // Trigger change event manually to simulate clearing
220
+ fireEvent.change(input, { target: { value: '' } });
221
+
222
+ await waitFor(() => {
223
+ expect(onChange).toHaveBeenCalledWith(undefined);
224
+ });
225
+ });
226
+ });
227
+
228
+ describe('User Editing', () => {
229
+ it('prevents unwanted conversions during editing', async () => {
230
+ const onChange = vi.fn();
231
+ const user = userEvent.setup();
232
+ const value = '2024-01-15T10:00:00Z';
233
+
234
+ render(<DateTimeField label="Start Time" value={value} onChange={onChange} />);
235
+
236
+ const input = screen.getByLabelText(/Start Time/) as HTMLInputElement;
237
+ await user.click(input);
238
+ await user.type(input, '2024-01-16');
239
+
240
+ // Should not trigger multiple onChange calls during typing
241
+ await waitFor(() => {
242
+ expect(onChange).toHaveBeenCalled();
243
+ });
244
+ });
245
+
246
+ it('handles blur event correctly', async () => {
247
+ const onChange = vi.fn();
248
+ const user = userEvent.setup();
249
+
250
+ render(<DateTimeField label="Start Time" value={undefined} onChange={onChange} />);
251
+
252
+ const input = screen.getByLabelText(/Start Time/) as HTMLInputElement;
253
+ await user.click(input);
254
+ await user.tab();
255
+
256
+ // Blur should stop editing mode
257
+ expect(input).not.toHaveFocus();
258
+ });
259
+ });
260
+
261
+ describe('Edge Cases', () => {
262
+ it('handles invalid date string gracefully', () => {
263
+ const onChange = vi.fn();
264
+ render(<DateTimeField label="Start Time" value="invalid-date" onChange={onChange} />);
265
+
266
+ const input = screen.getByLabelText(/Start Time/) as HTMLInputElement;
267
+ expect(input.value).toBe('');
268
+ });
269
+
270
+ it('handles null value gracefully', () => {
271
+ const onChange = vi.fn();
272
+ // @ts-expect-error - Testing edge case
273
+ render(<DateTimeField label="Start Time" value={null} onChange={onChange} />);
274
+
275
+ const input = screen.getByLabelText(/Start Time/) as HTMLInputElement;
276
+ expect(input.value).toBe('');
277
+ });
278
+
279
+ it('handles empty string value', () => {
280
+ const onChange = vi.fn();
281
+ render(<DateTimeField label="Start Time" value="" onChange={onChange} />);
282
+
283
+ const input = screen.getByLabelText(/Start Time/) as HTMLInputElement;
284
+ expect(input.value).toBe('');
285
+ });
286
+ });
287
+
288
+ describe('Accessibility', () => {
289
+ it('associates label with input via htmlFor', () => {
290
+ const onChange = vi.fn();
291
+ render(<DateTimeField label="Start Time" value={undefined} onChange={onChange} />);
292
+
293
+ const label = screen.getByText('Start Time');
294
+ const input = screen.getByLabelText(/Start Time/);
295
+
296
+ expect(label).toBeInTheDocument();
297
+ expect(input).toBeInTheDocument();
298
+ expect(input).toHaveAttribute('id');
299
+ });
300
+
301
+ it('supports keyboard navigation', async () => {
302
+ const onChange = vi.fn();
303
+ const user = userEvent.setup();
304
+
305
+ render(<DateTimeField label="Start Time" value={undefined} onChange={onChange} />);
306
+
307
+ const input = screen.getByLabelText(/Start Time/);
308
+ await user.tab();
309
+
310
+ expect(input).toHaveFocus();
311
+ });
312
+
313
+ it('announces errors to screen readers', () => {
314
+ const onChange = vi.fn();
315
+ render(
316
+ <DateTimeField
317
+ label="Start Time"
318
+ value={undefined}
319
+ onChange={onChange}
320
+ error="Invalid date"
321
+ />
322
+ );
323
+
324
+ const errorMessage = screen.getByRole('alert');
325
+ expect(errorMessage).toBeInTheDocument();
326
+ expect(errorMessage).toHaveTextContent('Invalid date');
327
+ });
328
+
329
+ it('indicates required field to screen readers', () => {
330
+ const onChange = vi.fn();
331
+ render(<DateTimeField label="Start Time" value={undefined} onChange={onChange} required />);
332
+
333
+ const requiredIndicator = screen.getByLabelText(/required/i);
334
+ expect(requiredIndicator).toBeInTheDocument();
335
+ });
336
+ });
337
+
338
+ describe('Controlled vs Uncontrolled', () => {
339
+ it('works as controlled component', () => {
340
+ const onChange = vi.fn();
341
+ const value = '2024-01-15T10:00:00Z';
342
+
343
+ const { rerender } = render(
344
+ <DateTimeField label="Start Time" value={value} onChange={onChange} />
345
+ );
346
+
347
+ const input = screen.getByLabelText(/Start Time/) as HTMLInputElement;
348
+ const initialValue = input.value;
349
+
350
+ rerender(
351
+ <DateTimeField label="Start Time" value="2024-01-16T10:00:00Z" onChange={onChange} />
352
+ );
353
+
354
+ expect(input.value).not.toBe(initialValue);
355
+ });
356
+ });
357
+ });
358
+
@@ -0,0 +1,232 @@
1
+ /**
2
+ * @file DateTimeField Component
3
+ * @package @jmruthers/pace-core
4
+ * @module Components/DateTimeField
5
+ * @since 0.1.0
6
+ *
7
+ * Form input component for datetime values with timezone support.
8
+ * Handles UTC ↔ timezone conversion automatically.
9
+ *
10
+ * Features:
11
+ * - Automatic UTC ↔ timezone conversion
12
+ * - Prevents unwanted conversions during user editing
13
+ * - Shows timezone information when not UTC
14
+ * - Supports both ISO string and Date object values
15
+ * - Uses native datetime-local input type
16
+ * - Accessible form field with proper labels
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * import { DateTimeField } from '@jmruthers/pace-core/components';
21
+ * import { useState } from 'react';
22
+ *
23
+ * function EventForm() {
24
+ * const [startTime, setStartTime] = useState<string>();
25
+ *
26
+ * return (
27
+ * <DateTimeField
28
+ * label="Start Time"
29
+ * value={startTime}
30
+ * onChange={setStartTime}
31
+ * timezone="America/New_York"
32
+ * required
33
+ * />
34
+ * );
35
+ * }
36
+ * ```
37
+ *
38
+ * @accessibility
39
+ * - Proper label association with htmlFor
40
+ * - Required field indicators
41
+ * - Screen reader friendly
42
+ * - Keyboard navigation support
43
+ * - Focus management
44
+ */
45
+
46
+ import * as React from 'react';
47
+ import { format, parse } from 'date-fns';
48
+ import { Label } from '../Label';
49
+ import { Input } from '../Input';
50
+ import { cn } from '../../utils/core/cn';
51
+ import { toZonedTime, fromZonedTime, getUserTimeZone } from '../../utils/timezone';
52
+
53
+ /**
54
+ * Props for the DateTimeField component
55
+ */
56
+ export interface DateTimeFieldProps {
57
+ /**
58
+ * Field label
59
+ */
60
+ label: string;
61
+ /**
62
+ * UTC date value (ISO string, Date object, or undefined)
63
+ */
64
+ value: string | Date | undefined;
65
+ /**
66
+ * Change handler that receives UTC value (ISO string or Date object)
67
+ */
68
+ onChange: (value: string | Date | undefined) => void;
69
+ /**
70
+ * Target timezone for display (default: 'UTC')
71
+ */
72
+ timezone?: string;
73
+ /**
74
+ * Whether the field is required
75
+ */
76
+ required?: boolean;
77
+ /**
78
+ * Additional CSS classes
79
+ */
80
+ className?: string;
81
+ /**
82
+ * If true, onChange returns Date object instead of ISO string
83
+ */
84
+ returnAsDate?: boolean;
85
+ /**
86
+ * Input id (auto-generated if not provided)
87
+ */
88
+ id?: string;
89
+ /**
90
+ * Helper text to display below the label
91
+ */
92
+ helperText?: string;
93
+ /**
94
+ * Error message to display
95
+ */
96
+ error?: string;
97
+ }
98
+
99
+ /**
100
+ * DateTimeField component
101
+ * Form input for datetime values with automatic timezone conversion
102
+ *
103
+ * @param props - DateTimeField configuration
104
+ * @returns JSX.Element - The rendered datetime field
105
+ */
106
+ export function DateTimeField({
107
+ label,
108
+ value,
109
+ onChange,
110
+ timezone = 'UTC',
111
+ required = false,
112
+ className,
113
+ returnAsDate = false,
114
+ id,
115
+ helperText,
116
+ error
117
+ }: DateTimeFieldProps) {
118
+ const [isEditing, setIsEditing] = React.useState(false);
119
+ const inputRef = React.useRef<HTMLInputElement>(null);
120
+ const fieldId = id || `datetime-field-${React.useId()}`;
121
+
122
+ // Convert UTC value to timezone for display
123
+ const getDisplayValue = React.useCallback((): string => {
124
+ if (!value) {
125
+ return '';
126
+ }
127
+
128
+ try {
129
+ let dateObj: Date;
130
+ if (typeof value === 'string') {
131
+ dateObj = new Date(value);
132
+ } else {
133
+ dateObj = value;
134
+ }
135
+
136
+ if (!dateObj || isNaN(dateObj.getTime())) {
137
+ return '';
138
+ }
139
+
140
+ // Convert UTC to timezone
141
+ const zonedDate = toZonedTime(dateObj, timezone);
142
+
143
+ // Format for datetime-local input (YYYY-MM-DDTHH:mm)
144
+ return format(zonedDate, "yyyy-MM-dd'T'HH:mm");
145
+ } catch {
146
+ return '';
147
+ }
148
+ }, [value, timezone]);
149
+
150
+ const displayValue = isEditing ? undefined : getDisplayValue();
151
+
152
+ // Handle input change
153
+ const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
154
+ setIsEditing(true);
155
+ const inputValue = e.target.value;
156
+
157
+ if (!inputValue) {
158
+ onChange(undefined);
159
+ return;
160
+ }
161
+
162
+ try {
163
+ // Parse the datetime-local value (in timezone)
164
+ const localDate = parse(inputValue, "yyyy-MM-dd'T'HH:mm", new Date());
165
+
166
+ if (isNaN(localDate.getTime())) {
167
+ onChange(undefined);
168
+ return;
169
+ }
170
+
171
+ // Convert from timezone to UTC
172
+ const utcDate = fromZonedTime(localDate, timezone);
173
+
174
+ // Return as ISO string or Date object
175
+ if (returnAsDate) {
176
+ onChange(utcDate);
177
+ } else {
178
+ onChange(utcDate.toISOString());
179
+ }
180
+ } catch {
181
+ onChange(undefined);
182
+ }
183
+ }, [timezone, returnAsDate, onChange]);
184
+
185
+ // Handle blur to stop editing mode
186
+ const handleBlur = React.useCallback(() => {
187
+ setIsEditing(false);
188
+ }, []);
189
+
190
+ // Get timezone display text
191
+ const getTimezoneDisplay = React.useCallback((): string => {
192
+ if (timezone === 'UTC') {
193
+ return '';
194
+ }
195
+
196
+ const userTz = getUserTimeZone();
197
+ if (timezone === userTz) {
198
+ return 'Local';
199
+ }
200
+
201
+ return timezone;
202
+ }, [timezone]);
203
+
204
+ const timezoneDisplay = getTimezoneDisplay();
205
+
206
+ return (
207
+ <div className={cn('space-y-2', className)}>
208
+ <Label htmlFor={fieldId} required={required} helperText={helperText} error={error}>
209
+ {label}
210
+ </Label>
211
+ <div className="relative">
212
+ <Input
213
+ ref={inputRef}
214
+ id={fieldId}
215
+ type="datetime-local"
216
+ value={displayValue}
217
+ onChange={handleChange}
218
+ onBlur={handleBlur}
219
+ required={required}
220
+ error={!!error}
221
+ className="w-full"
222
+ />
223
+ {timezoneDisplay && (
224
+ <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground pointer-events-none">
225
+ {timezoneDisplay}
226
+ </span>
227
+ )}
228
+ </div>
229
+ </div>
230
+ );
231
+ }
232
+