@jmruthers/pace-core 0.6.4 → 0.6.6

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 (387) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +5 -403
  3. package/core-usage-manifest.json +93 -0
  4. package/cursor-rules/00-pace-core-compliance.mdc +128 -26
  5. package/cursor-rules/01-standards-compliance.mdc +49 -8
  6. package/cursor-rules/02-project-structure.mdc +6 -0
  7. package/cursor-rules/03-solid-principles.mdc +2 -0
  8. package/cursor-rules/04-testing-standards.mdc +2 -0
  9. package/cursor-rules/05-bug-reports-and-features.mdc +2 -0
  10. package/cursor-rules/06-code-quality.mdc +2 -0
  11. package/cursor-rules/07-tech-stack-compliance.mdc +2 -0
  12. package/cursor-rules/08-markup-quality.mdc +52 -27
  13. package/cursor-rules/09-rbac-compliance.mdc +462 -0
  14. package/cursor-rules/10-error-handling-patterns.mdc +179 -0
  15. package/cursor-rules/11-performance-optimization.mdc +169 -0
  16. package/cursor-rules/12-ci-cd-integration.mdc +150 -0
  17. package/dist/{AuthService-Cb34EQs3.d.ts → AuthService-DmfO5rGS.d.ts} +10 -0
  18. package/dist/{DataTable-BMRU8a1j.d.ts → DataTable-2N_tqbfq.d.ts} +1 -1
  19. package/dist/DataTable-LRJL4IRV.js +15 -0
  20. package/dist/{PublicPageProvider-DEMpysFR.d.ts → PublicPageProvider-BBH6Vqg7.d.ts} +72 -139
  21. package/dist/UnifiedAuthProvider-ZT6TIGM7.js +7 -0
  22. package/dist/api-Y4MQWOFW.js +4 -0
  23. package/dist/audit-MYQXYZFU.js +3 -0
  24. package/dist/{chunk-J36DSWQK.js → chunk-2HGJFNAH.js} +8 -28
  25. package/dist/{chunk-OEWDTMG7.js → chunk-3O3WHILE.js} +38 -121
  26. package/dist/{chunk-M43Y4SSO.js → chunk-3QC3KRHK.js} +1 -14
  27. package/dist/{chunk-DGUM43GV.js → chunk-3RG5ZIWI.js} +1 -4
  28. package/dist/{chunk-QXHPKYJV.js → chunk-4SXLQIZO.js} +1 -26
  29. package/dist/chunk-4T7OBVTU.js +62 -0
  30. package/dist/{chunk-E66EQZE6.js → chunk-6GLLNA6U.js} +3 -9
  31. package/dist/{chunk-ZSAAAMVR.js → chunk-6QYDGKQY.js} +1 -4
  32. package/dist/{chunk-NN6WWZ5U.js → chunk-7TYHROIV.js} +579 -563
  33. package/dist/{chunk-M7MPQISP.js → chunk-A55DK444.js} +9 -16
  34. package/dist/{chunk-63FOKYGO.js → chunk-AHU7G2R5.js} +2 -11
  35. package/dist/{chunk-L4OXEN46.js → chunk-BVP2BCJF.js} +2 -16
  36. package/dist/chunk-C7NSAPTL.js +1 -0
  37. package/dist/{chunk-YKRAFF5K.js → chunk-FENMYN2U.js} +73 -149
  38. package/dist/{chunk-AVMLPIM7.js → chunk-FTCRZOG2.js} +284 -432
  39. package/dist/{chunk-G37KK66H.js → chunk-FYHN4DD5.js} +60 -19
  40. package/dist/{chunk-VBXEHIUJ.js → chunk-HF6O3O37.js} +6 -88
  41. package/dist/{chunk-I6DAQMWX.js → chunk-LAZMKTTF.js} +930 -891
  42. package/dist/{chunk-5EC5MEWX.js → chunk-MAGBIDNS.js} +77 -222
  43. package/dist/chunk-MBADTM7L.js +64 -0
  44. package/dist/chunk-OHIK3MIO.js +994 -0
  45. package/dist/{chunk-6SOIHG6Z.js → chunk-S7DKJPLT.js} +115 -44
  46. package/dist/{chunk-FMUCXFII.js → chunk-SD6WQY43.js} +1 -5
  47. package/dist/{chunk-PWLANIRT.js → chunk-TTRFSOKR.js} +1 -7
  48. package/dist/{chunk-5DRSZLL2.js → chunk-UH3NTO3F.js} +1 -6
  49. package/dist/{chunk-FFQEQTNW.js → chunk-UIYSCEV7.js} +134 -45
  50. package/dist/{chunk-3LPHPB62.js → chunk-ZFYPMX46.js} +271 -87
  51. package/dist/{chunk-7JPAB3T5.js → chunk-ZS5VO5JB.js} +1989 -1283
  52. package/dist/components.d.ts +6 -6
  53. package/dist/components.js +57 -267
  54. package/dist/{database.generated-CzIvgcPu.d.ts → database.generated-CcnC_DRc.d.ts} +4795 -3691
  55. package/dist/eslint-rules/index.cjs +22 -0
  56. package/dist/eslint-rules/rules/compliance.cjs +348 -0
  57. package/dist/eslint-rules/rules/components.cjs +113 -0
  58. package/dist/eslint-rules/rules/imports.cjs +102 -0
  59. package/dist/eslint-rules/rules/rbac.cjs +790 -0
  60. package/dist/eslint-rules/utils/helpers.cjs +42 -0
  61. package/dist/eslint-rules/utils/manifest-loader.cjs +75 -0
  62. package/dist/hooks.d.ts +5 -5
  63. package/dist/hooks.js +62 -270
  64. package/dist/icons/index.d.ts +1 -0
  65. package/dist/icons/index.js +1 -0
  66. package/dist/index.d.ts +36 -26
  67. package/dist/index.js +87 -690
  68. package/dist/providers.d.ts +2 -2
  69. package/dist/providers.js +8 -35
  70. package/dist/rbac/eslint-rules.d.ts +46 -44
  71. package/dist/rbac/eslint-rules.js +7 -4
  72. package/dist/rbac/index.d.ts +124 -594
  73. package/dist/rbac/index.js +14 -207
  74. package/dist/styles/index.js +2 -12
  75. package/dist/theming/runtime.js +3 -19
  76. package/dist/{timezone-CHhWg6b4.d.ts → timezone-BZe_eUxx.d.ts} +175 -1
  77. package/dist/{types-CkbwOr4Y.d.ts → types-B-K_5VnO.d.ts} +4 -0
  78. package/dist/types-t9H8qKRw.d.ts +55 -0
  79. package/dist/types.d.ts +1 -1
  80. package/dist/types.js +7 -94
  81. package/dist/{usePublicRouteParams-i3qtoBgg.d.ts → usePublicRouteParams-COZ28Mvq.d.ts} +9 -9
  82. package/dist/utils.d.ts +24 -117
  83. package/dist/utils.js +54 -392
  84. package/docs/README.md +16 -6
  85. package/docs/api/README.md +4 -402
  86. package/docs/api/modules.md +454 -930
  87. package/docs/api-reference/components.md +3 -1
  88. package/docs/api-reference/deprecated.md +31 -6
  89. package/docs/api-reference/rpc-functions.md +78 -3
  90. package/docs/best-practices/accessibility.md +6 -3
  91. package/docs/getting-started/cursor-rules.md +3 -23
  92. package/docs/getting-started/dependencies.md +650 -0
  93. package/docs/getting-started/installation-guide.md +20 -7
  94. package/docs/getting-started/quick-start.md +23 -12
  95. package/docs/implementation-guides/permission-enforcement.md +4 -0
  96. package/docs/rbac/MIGRATION_GUIDE.md +819 -0
  97. package/docs/rbac/RBAC_CONTRACT.md +724 -0
  98. package/docs/rbac/README.md +12 -3
  99. package/docs/rbac/edge-functions-guide.md +376 -0
  100. package/docs/rbac/secure-client-protection.md +0 -34
  101. package/docs/standards/00-pace-core-compliance.md +967 -0
  102. package/docs/standards/01-standards-compliance.md +188 -0
  103. package/docs/standards/02-project-structure.md +985 -0
  104. package/docs/standards/03-solid-principles.md +39 -0
  105. package/docs/standards/04-testing-standards.md +36 -0
  106. package/docs/standards/05-bug-reports-and-features.md +27 -0
  107. package/docs/standards/{04-code-style-standard.md → 06-code-quality.md} +2 -0
  108. package/docs/standards/07-tech-stack-compliance.md +30 -0
  109. package/docs/standards/08-markup-quality.md +345 -0
  110. package/docs/standards/{07-rbac-and-rls-standard.md → 09-rbac-compliance.md} +149 -54
  111. package/docs/standards/10-error-handling-patterns.md +401 -0
  112. package/docs/standards/11-performance-optimization.md +348 -0
  113. package/docs/standards/12-ci-cd-integration.md +370 -0
  114. package/docs/standards/ALIGNMENT_REVIEW_SUMMARY.md +192 -0
  115. package/docs/standards/README.md +62 -33
  116. package/docs/troubleshooting/organisation-context-setup.md +42 -19
  117. package/eslint-config-pace-core.cjs +20 -4
  118. package/package.json +31 -21
  119. package/scripts/audit/audit-compliance.cjs +1295 -0
  120. package/scripts/audit/audit-components.cjs +260 -0
  121. package/scripts/audit/audit-dependencies.cjs +395 -0
  122. package/scripts/audit/audit-rbac.cjs +954 -0
  123. package/scripts/audit/audit-standards.cjs +1268 -0
  124. package/scripts/audit/index.cjs +1898 -194
  125. package/scripts/install-cursor-rules.cjs +259 -8
  126. package/scripts/validate-master.js +1 -1
  127. package/src/__tests__/fixtures/supabase.ts +1 -1
  128. package/src/__tests__/helpers/__tests__/component-test-utils.test.tsx +1 -1
  129. package/src/__tests__/helpers/__tests__/optimized-test-setup.test.ts +1 -1
  130. package/src/__tests__/helpers/__tests__/supabaseMock.test.ts +1 -1
  131. package/src/__tests__/helpers/__tests__/test-utils.test.tsx +3 -3
  132. package/src/__tests__/helpers/component-test-utils.tsx +1 -1
  133. package/src/__tests__/helpers/supabaseMock.ts +2 -2
  134. package/src/__tests__/public-recipe-view.test.ts +38 -9
  135. package/src/components/Button/Button.tsx +5 -1
  136. package/src/components/ContextSelector/ContextSelector.tsx +42 -39
  137. package/src/components/DataTable/__tests__/keyboard.test.tsx +15 -2
  138. package/src/components/DataTable/components/DataTableBody.tsx +55 -31
  139. package/src/components/DataTable/components/DataTableCore.tsx +186 -13
  140. package/src/components/DataTable/components/DataTableLayout.tsx +30 -5
  141. package/src/components/DataTable/components/EditFields.tsx +23 -3
  142. package/src/components/DataTable/components/EditableRow.tsx +7 -2
  143. package/src/components/DataTable/components/ImportModal.tsx +4 -6
  144. package/src/components/DataTable/components/RowComponent.tsx +12 -0
  145. package/src/components/DataTable/components/ViewRowModal.tsx +4 -4
  146. package/src/components/DataTable/components/__tests__/ImportModal.test.tsx +455 -96
  147. package/src/components/DataTable/components/__tests__/ViewRowModal.test.tsx +122 -58
  148. package/src/components/DataTable/components/hooks/usePermissionTracking.ts +0 -4
  149. package/src/components/DataTable/core/DataTableContext.tsx +1 -1
  150. package/src/components/DataTable/hooks/__tests__/useDataTableState.test.ts +51 -47
  151. package/src/components/DataTable/hooks/useDataTablePermissions.ts +24 -21
  152. package/src/components/DataTable/hooks/useDataTableState.ts +125 -9
  153. package/src/components/DataTable/hooks/useTableColumns.ts +40 -2
  154. package/src/components/DataTable/hooks/useTableHandlers.ts +11 -0
  155. package/src/components/DataTable/types.ts +5 -0
  156. package/src/components/DateTimeField/DateTimeField.tsx +20 -20
  157. package/src/components/DateTimeField/README.md +5 -2
  158. package/src/components/Dialog/Dialog.test.tsx +361 -318
  159. package/src/components/Dialog/Dialog.tsx +1154 -323
  160. package/src/components/Dialog/index.ts +3 -3
  161. package/src/components/FileDisplay/FileDisplay.test.tsx +45 -2
  162. package/src/components/FileDisplay/FileDisplay.tsx +28 -22
  163. package/src/components/Form/Form.test.tsx +9 -10
  164. package/src/components/Form/Form.tsx +369 -9
  165. package/src/components/InactivityWarningModal/InactivityWarningModal.test.tsx +28 -28
  166. package/src/components/InactivityWarningModal/InactivityWarningModal.tsx +40 -54
  167. package/src/components/LoginForm/LoginForm.tsx +2 -2
  168. package/src/components/NavigationMenu/NavigationMenu.test.tsx +14 -13
  169. package/src/components/NavigationMenu/NavigationMenu.tsx +2 -2
  170. package/src/components/NavigationMenu/useNavigationFiltering.ts +11 -21
  171. package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +6 -4
  172. package/src/components/PaceAppLayout/PaceAppLayout.tsx +30 -41
  173. package/src/components/PaceAppLayout/README.md +10 -9
  174. package/src/components/PaceAppLayout/test-setup.tsx +40 -31
  175. package/src/components/PaceLoginPage/PaceLoginPage.test.tsx +108 -61
  176. package/src/components/PaceLoginPage/PaceLoginPage.tsx +27 -3
  177. package/src/components/PasswordChange/PasswordChangeForm.test.tsx +61 -0
  178. package/src/components/PasswordChange/PasswordChangeForm.tsx +20 -13
  179. package/src/components/PublicLayout/PublicLayout.test.tsx +7 -3
  180. package/src/components/PublicLayout/PublicPageLayout.tsx +5 -8
  181. package/src/components/Select/Select.tsx +23 -21
  182. package/src/components/Select/types.ts +1 -1
  183. package/src/components/UserMenu/UserMenu.test.tsx +38 -6
  184. package/src/components/UserMenu/UserMenu.tsx +39 -34
  185. package/src/components/index.ts +3 -4
  186. package/src/eslint-rules/index.cjs +22 -0
  187. package/src/eslint-rules/rules/compliance.cjs +348 -0
  188. package/src/eslint-rules/rules/components.cjs +113 -0
  189. package/src/eslint-rules/rules/imports.cjs +102 -0
  190. package/src/eslint-rules/rules/rbac.cjs +790 -0
  191. package/src/eslint-rules/utils/helpers.cjs +42 -0
  192. package/src/eslint-rules/utils/manifest-loader.cjs +75 -0
  193. package/src/hooks/__tests__/hooks.integration.test.tsx +6 -8
  194. package/src/hooks/__tests__/useAppConfig.unit.test.ts +129 -67
  195. package/src/hooks/__tests__/usePublicEvent.simple.test.ts +149 -67
  196. package/src/hooks/__tests__/usePublicEvent.test.ts +149 -79
  197. package/src/hooks/__tests__/usePublicEvent.unit.test.ts +158 -109
  198. package/src/hooks/__tests__/useSessionDraft.test.ts +163 -0
  199. package/src/hooks/__tests__/useSessionRestoration.unit.test.tsx +10 -5
  200. package/src/hooks/public/usePublicEvent.ts +62 -190
  201. package/src/hooks/public/usePublicEventLogo.test.ts +70 -17
  202. package/src/hooks/public/usePublicEventLogo.ts +19 -9
  203. package/src/hooks/useAppConfig.ts +26 -24
  204. package/src/hooks/useEventTheme.test.ts +211 -233
  205. package/src/hooks/useEventTheme.ts +19 -28
  206. package/src/hooks/useEvents.ts +11 -7
  207. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  208. package/src/hooks/useOrganisationPermissions.ts +9 -11
  209. package/src/hooks/useOrganisations.ts +13 -7
  210. package/src/hooks/useQueryCache.ts +0 -1
  211. package/src/hooks/useSessionDraft.ts +380 -0
  212. package/src/hooks/useSessionRestoration.ts +3 -1
  213. package/src/icons/index.ts +27 -0
  214. package/src/index.ts +16 -1
  215. package/src/providers/OrganisationProvider.tsx +23 -14
  216. package/src/providers/services/EventServiceProvider.tsx +1 -24
  217. package/src/providers/services/UnifiedAuthProvider.tsx +5 -48
  218. package/src/providers/services/__tests__/UnifiedAuthProvider.integration.test.tsx +3 -0
  219. package/src/rbac/README.md +20 -20
  220. package/src/rbac/__tests__/adapters.comprehensive.test.tsx +7 -457
  221. package/src/rbac/__tests__/auth-rbac.e2e.test.tsx +33 -7
  222. package/src/rbac/adapters.tsx +7 -295
  223. package/src/rbac/api.test.ts +44 -56
  224. package/src/rbac/api.ts +10 -17
  225. package/src/rbac/cache-invalidation.ts +0 -1
  226. package/src/rbac/compliance/index.ts +10 -0
  227. package/src/rbac/compliance/pattern-detector.ts +553 -0
  228. package/src/rbac/compliance/runtime-compliance.ts +22 -0
  229. package/src/rbac/components/AccessDenied.tsx +150 -0
  230. package/src/rbac/components/NavigationGuard.tsx +12 -20
  231. package/src/rbac/components/PagePermissionGuard.tsx +4 -24
  232. package/src/rbac/components/__tests__/NavigationGuard.test.tsx +21 -8
  233. package/src/rbac/components/index.ts +3 -41
  234. package/src/rbac/eslint-rules.js +1 -1
  235. package/src/rbac/hooks/index.ts +0 -3
  236. package/src/rbac/hooks/permissions/index.ts +0 -3
  237. package/src/rbac/hooks/permissions/useAccessLevel.ts +4 -8
  238. package/src/rbac/hooks/usePermissions.ts +0 -3
  239. package/src/rbac/hooks/useRBAC.test.ts +21 -3
  240. package/src/rbac/hooks/useRBAC.ts +4 -3
  241. package/src/rbac/hooks/useResolvedScope.test.ts +57 -47
  242. package/src/rbac/hooks/useResolvedScope.ts +58 -140
  243. package/src/rbac/hooks/useResourcePermissions.test.ts +241 -60
  244. package/src/rbac/hooks/useResourcePermissions.ts +182 -63
  245. package/src/rbac/hooks/useRoleManagement.test.ts +65 -22
  246. package/src/rbac/hooks/useRoleManagement.ts +147 -19
  247. package/src/rbac/hooks/useSecureSupabase.ts +4 -8
  248. package/src/rbac/index.ts +7 -9
  249. package/src/rbac/permissions.ts +17 -17
  250. package/src/rbac/utils/contextValidator.ts +45 -7
  251. package/src/services/AuthService.ts +132 -23
  252. package/src/services/EventService.ts +4 -97
  253. package/src/services/InactivityService.ts +155 -58
  254. package/src/services/OrganisationService.ts +7 -44
  255. package/src/services/__tests__/OrganisationService.test.ts +26 -8
  256. package/src/services/base/BaseService.ts +0 -3
  257. package/src/styles/core.css +4 -0
  258. package/src/types/database.generated.ts +4733 -3809
  259. package/src/utils/__tests__/organisationContext.unit.test.ts +9 -10
  260. package/src/utils/context/organisationContext.test.ts +13 -28
  261. package/src/utils/context/organisationContext.ts +21 -52
  262. package/src/utils/dynamic/dynamicUtils.ts +1 -1
  263. package/src/utils/file-reference/index.ts +39 -15
  264. package/src/utils/formatting/formatDateTime.test.ts +3 -2
  265. package/src/utils/formatting/formatTime.test.ts +3 -2
  266. package/src/utils/google-places/loadGoogleMapsScript.ts +29 -4
  267. package/src/utils/index.ts +4 -1
  268. package/src/utils/persistence/__tests__/keyDerivation.test.ts +135 -0
  269. package/src/utils/persistence/__tests__/sensitiveFieldDetection.test.ts +123 -0
  270. package/src/utils/persistence/keyDerivation.ts +304 -0
  271. package/src/utils/persistence/sensitiveFieldDetection.ts +212 -0
  272. package/src/utils/security/secureStorage.ts +5 -5
  273. package/src/utils/storage/helpers.ts +3 -3
  274. package/src/utils/supabase/createBaseClient.ts +147 -0
  275. package/src/utils/timezone/timezone.test.ts +1 -2
  276. package/src/utils/timezone/timezone.ts +1 -1
  277. package/src/utils/validation/csrf.ts +4 -4
  278. package/cursor-rules/CHANGELOG.md +0 -119
  279. package/cursor-rules/README.md +0 -192
  280. package/dist/DataTable-E7YQZD7D.js +0 -175
  281. package/dist/DataTable-E7YQZD7D.js.map +0 -1
  282. package/dist/UnifiedAuthProvider-QPXO24B4.js +0 -18
  283. package/dist/UnifiedAuthProvider-QPXO24B4.js.map +0 -1
  284. package/dist/api-6LVZTHDS.js +0 -52
  285. package/dist/api-6LVZTHDS.js.map +0 -1
  286. package/dist/audit-V53FV5AG.js +0 -17
  287. package/dist/audit-V53FV5AG.js.map +0 -1
  288. package/dist/chunk-36LVWXB2.js +0 -227
  289. package/dist/chunk-36LVWXB2.js.map +0 -1
  290. package/dist/chunk-3LPHPB62.js.map +0 -1
  291. package/dist/chunk-5DRSZLL2.js.map +0 -1
  292. package/dist/chunk-5EC5MEWX.js.map +0 -1
  293. package/dist/chunk-63FOKYGO.js.map +0 -1
  294. package/dist/chunk-6SOIHG6Z.js.map +0 -1
  295. package/dist/chunk-7JPAB3T5.js.map +0 -1
  296. package/dist/chunk-ATKZM7RX.js +0 -2053
  297. package/dist/chunk-ATKZM7RX.js.map +0 -1
  298. package/dist/chunk-AVMLPIM7.js.map +0 -1
  299. package/dist/chunk-DGUM43GV.js.map +0 -1
  300. package/dist/chunk-E66EQZE6.js.map +0 -1
  301. package/dist/chunk-FFQEQTNW.js.map +0 -1
  302. package/dist/chunk-FMUCXFII.js.map +0 -1
  303. package/dist/chunk-G37KK66H.js.map +0 -1
  304. package/dist/chunk-I6DAQMWX.js.map +0 -1
  305. package/dist/chunk-J36DSWQK.js.map +0 -1
  306. package/dist/chunk-KQCRWDSA.js +0 -1
  307. package/dist/chunk-KQCRWDSA.js.map +0 -1
  308. package/dist/chunk-L4OXEN46.js.map +0 -1
  309. package/dist/chunk-LMC26NLJ.js +0 -84
  310. package/dist/chunk-LMC26NLJ.js.map +0 -1
  311. package/dist/chunk-M43Y4SSO.js.map +0 -1
  312. package/dist/chunk-M7MPQISP.js.map +0 -1
  313. package/dist/chunk-NN6WWZ5U.js.map +0 -1
  314. package/dist/chunk-OEWDTMG7.js.map +0 -1
  315. package/dist/chunk-PWLANIRT.js.map +0 -1
  316. package/dist/chunk-QXHPKYJV.js.map +0 -1
  317. package/dist/chunk-VBXEHIUJ.js.map +0 -1
  318. package/dist/chunk-YKRAFF5K.js.map +0 -1
  319. package/dist/chunk-ZSAAAMVR.js.map +0 -1
  320. package/dist/components.js.map +0 -1
  321. package/dist/contextValidator-OOPCLPZW.js +0 -9
  322. package/dist/contextValidator-OOPCLPZW.js.map +0 -1
  323. package/dist/eslint-rules/pace-core-compliance.cjs +0 -510
  324. package/dist/hooks.js.map +0 -1
  325. package/dist/index.js.map +0 -1
  326. package/dist/providers.js.map +0 -1
  327. package/dist/rbac/eslint-rules.js.map +0 -1
  328. package/dist/rbac/index.js.map +0 -1
  329. package/dist/styles/index.js.map +0 -1
  330. package/dist/theming/runtime.js.map +0 -1
  331. package/dist/types.js.map +0 -1
  332. package/dist/utils.js.map +0 -1
  333. package/docs/standards/01-architecture-standard.md +0 -44
  334. package/docs/standards/02-api-and-rpc-standard.md +0 -39
  335. package/docs/standards/03-component-standard.md +0 -32
  336. package/docs/standards/05-security-standard.md +0 -44
  337. package/docs/standards/06-testing-and-docs-standard.md +0 -29
  338. package/docs/standards/pace-core-compliance.md +0 -432
  339. package/scripts/audit/core/checks/accessibility.cjs +0 -197
  340. package/scripts/audit/core/checks/api-usage.cjs +0 -191
  341. package/scripts/audit/core/checks/bundle.cjs +0 -142
  342. package/scripts/audit/core/checks/compliance.cjs +0 -2706
  343. package/scripts/audit/core/checks/config.cjs +0 -54
  344. package/scripts/audit/core/checks/coverage.cjs +0 -84
  345. package/scripts/audit/core/checks/dependencies.cjs +0 -994
  346. package/scripts/audit/core/checks/documentation.cjs +0 -268
  347. package/scripts/audit/core/checks/environment.cjs +0 -116
  348. package/scripts/audit/core/checks/error-handling.cjs +0 -340
  349. package/scripts/audit/core/checks/forms.cjs +0 -172
  350. package/scripts/audit/core/checks/heuristics.cjs +0 -68
  351. package/scripts/audit/core/checks/hooks.cjs +0 -334
  352. package/scripts/audit/core/checks/imports.cjs +0 -244
  353. package/scripts/audit/core/checks/performance.cjs +0 -325
  354. package/scripts/audit/core/checks/routes.cjs +0 -117
  355. package/scripts/audit/core/checks/state.cjs +0 -130
  356. package/scripts/audit/core/checks/structure.cjs +0 -65
  357. package/scripts/audit/core/checks/style.cjs +0 -584
  358. package/scripts/audit/core/checks/testing.cjs +0 -122
  359. package/scripts/audit/core/checks/typescript.cjs +0 -61
  360. package/scripts/audit/core/scanner.cjs +0 -199
  361. package/scripts/audit/core/utils.cjs +0 -137
  362. package/scripts/audit/reporters/console.cjs +0 -151
  363. package/scripts/audit/reporters/json.cjs +0 -54
  364. package/scripts/audit/reporters/markdown.cjs +0 -124
  365. package/scripts/audit-consuming-app.cjs +0 -86
  366. package/src/eslint-rules/pace-core-compliance.cjs +0 -510
  367. package/src/eslint-rules/pace-core-compliance.js +0 -638
  368. package/src/rbac/components/EnhancedNavigationMenu.test.tsx +0 -555
  369. package/src/rbac/components/EnhancedNavigationMenu.tsx +0 -293
  370. package/src/rbac/components/NavigationProvider.test.tsx +0 -481
  371. package/src/rbac/components/NavigationProvider.tsx +0 -345
  372. package/src/rbac/components/PagePermissionProvider.test.tsx +0 -476
  373. package/src/rbac/components/PagePermissionProvider.tsx +0 -279
  374. package/src/rbac/components/PermissionEnforcer.tsx +0 -312
  375. package/src/rbac/components/RoleBasedRouter.tsx +0 -440
  376. package/src/rbac/components/SecureDataProvider.test.tsx +0 -543
  377. package/src/rbac/components/SecureDataProvider.tsx +0 -339
  378. package/src/rbac/components/__tests__/EnhancedNavigationMenu.test.tsx +0 -620
  379. package/src/rbac/components/__tests__/NavigationProvider.test.tsx +0 -726
  380. package/src/rbac/components/__tests__/PagePermissionProvider.test.tsx +0 -661
  381. package/src/rbac/components/__tests__/PermissionEnforcer.test.tsx +0 -881
  382. package/src/rbac/components/__tests__/RoleBasedRouter.test.tsx +0 -783
  383. package/src/rbac/components/__tests__/SecureDataProvider.fixed.test.tsx +0 -645
  384. package/src/rbac/components/__tests__/SecureDataProvider.test.tsx +0 -659
  385. package/src/rbac/hooks/permissions/useCachedPermissions.ts +0 -79
  386. package/src/rbac/hooks/permissions/useHasAllPermissions.ts +0 -90
  387. package/src/rbac/hooks/permissions/useHasAnyPermission.ts +0 -90
@@ -4,7 +4,7 @@
4
4
  * @module Components/Dialog
5
5
  * @since 0.1.0
6
6
  *
7
- * A comprehensive dialog component system built on top of Radix UI primitives.
7
+ * A comprehensive dialog component system using native HTML `<dialog>` element.
8
8
  * Provides accessible modal dialogs with focus management and keyboard navigation.
9
9
  * Uses semantic HTML elements including native <dialog> element for maximum accessibility.
10
10
  *
@@ -24,8 +24,9 @@
24
24
  * - Sticky headers/footers with scrollable body
25
25
  * - Overlay backdrop with customization
26
26
  * - Close button with accessibility (optional)
27
- * - Header, footer, title, and description components
27
+ * - Header and footer components
28
28
  * - Configurable close behaviors
29
+ * - Native dialog title and aria-description attributes for accessibility
29
30
  *
30
31
  * @example
31
32
  * ```tsx
@@ -34,12 +35,9 @@
34
35
  * <DialogTrigger asChild>
35
36
  * <Button>Open Dialog</Button>
36
37
  * </DialogTrigger>
37
- * <DialogContent size="lg">
38
+ * <DialogContent size="lg" title="Edit Profile" description="Make changes to your profile here. Click save when you're done.">
38
39
  * <DialogHeader>
39
- * <DialogTitle>Edit Profile</DialogTitle>
40
- * <DialogDescription>
41
- * Make changes to your profile here. Click save when you're done.
42
- * </DialogDescription>
40
+ * <h2>Edit Profile</h2>
43
41
  * </DialogHeader>
44
42
  * <DialogBody>
45
43
  * <section className="space-y-4">
@@ -54,81 +52,12 @@
54
52
  * </DialogFooter>
55
53
  * </DialogContent>
56
54
  * </Dialog>
57
- *
58
- * // Dialog with semantic scrolling content
59
- * <Dialog>
60
- * <DialogTrigger asChild>
61
- * <Button>Scrollable Dialog</Button>
62
- * </DialogTrigger>
63
- * <DialogContent
64
- * size="lg"
65
- * enableScrolling={true}
66
- * maxHeightPercent={80}
67
- * >
68
- * <DialogHeader>
69
- * <DialogTitle>Large Content Dialog</DialogTitle>
70
- * <DialogDescription>
71
- * This dialog has lots of content and will scroll if needed.
72
- * </DialogDescription>
73
- * </DialogHeader>
74
- * <DialogBody>
75
- * <section className="space-y-4">
76
- * {Array.from({ length: 50 }, (_, i) => (
77
- * <article key={i}>
78
- * <h4>Content Item {i + 1}</h4>
79
- * <p>This is semantic content within the dialog body.</p>
80
- * </article>
81
- * ))}
82
- * </section>
83
- * </DialogBody>
84
- * <DialogFooter>
85
- * <Button>Save</Button>
86
- * </DialogFooter>
87
- * </DialogContent>
88
- * </Dialog>
89
- *
90
- * // Auto-sizing dialog that fits content
91
- * <Dialog>
92
- * <DialogTrigger asChild>
93
- * <Button>Auto Size Dialog</Button>
94
- * </DialogTrigger>
95
- * <DialogContent size="auto">
96
- * <DialogHeader>
97
- * <DialogTitle>Auto-Sized Dialog</DialogTitle>
98
- * <DialogDescription>
99
- * This dialog automatically adjusts its width to fit the content.
100
- * </DialogDescription>
101
- * </DialogHeader>
102
- * <DialogBody>
103
- * <section>
104
- * <p>Content that determines the dialog width...</p>
105
- * </section>
106
- * </DialogBody>
107
- * </DialogContent>
108
- * </Dialog>
109
- *
110
- * // Full-screen dialog with semantic structure
111
- * <Dialog>
112
- * <DialogTrigger asChild>
113
- * <Button>Full Screen</Button>
114
- * </DialogTrigger>
115
- * <DialogContent size="full">
116
- * <DialogHeader>
117
- * <DialogTitle>Full Screen Dialog</DialogTitle>
118
- * </DialogHeader>
119
- * <DialogBody>
120
- * <section>
121
- * <p>Full screen content with semantic structure...</p>
122
- * </section>
123
- * </DialogBody>
124
- * </DialogContent>
125
- * </Dialog>
126
55
  * ```
127
56
  *
128
57
  * @accessibility
129
58
  * - WCAG 2.1 AA compliant
130
59
  * - Uses semantic HTML structure (dialog, header, main, footer)
131
- * - Native dialog element with enhanced ARIA attributes via Radix UI
60
+ * - Native dialog element with proper ARIA attributes
132
61
  * - Focus trapping within dialog content
133
62
  * - Keyboard navigation support
134
63
  * - Screen reader announcements
@@ -147,18 +76,28 @@
147
76
  * - Optimized scroll handling
148
77
  *
149
78
  * @dependencies
150
- * - @radix-ui/react-dialog - Core dialog functionality
151
79
  * - lucide-react - Icons
152
- * - React 19+ - Hooks and refs
80
+ * - React 19+ - Hooks, refs, and createPortal
153
81
  * - Tailwind CSS - Styling and animations
82
+ *
83
+ * @note
84
+ * This component uses native HTML dialog element with manual focus management.
85
+ * Title and description are provided via props on DialogContent, which set the native
86
+ * title and aria-description attributes on the dialog element for accessibility.
87
+ * See https://developer.mozilla.org/en-US/docs/Web/API/Element/ariaDescription for details.
154
88
  */
155
89
 
156
90
  import * as React from 'react';
157
- import * as DialogPrimitive from '@radix-ui/react-dialog';
91
+ import { createPortal } from 'react-dom';
158
92
  import { X } from 'lucide-react';
93
+ import { useLocation } from 'react-router-dom';
94
+ import { useUnifiedAuth } from '../../providers/services/UnifiedAuthProvider';
159
95
  import { cn } from '../../utils/core/cn';
160
96
  import { renderSafeHtml } from '../../utils/validation/htmlSanitization';
161
- import { useState, useEffect } from 'react';
97
+ import { useState, useEffect, useRef, useCallback, useId, useMemo } from 'react';
98
+ import { useFocusTrap } from '../../hooks/useFocusTrap';
99
+ import { useSessionDraft } from '../../hooks/useSessionDraft';
100
+ import { deriveDialogKey } from '../../utils/persistence/keyDerivation';
162
101
 
163
102
  /**
164
103
  * Simple debounce function that matches lodash debounce API
@@ -195,30 +134,64 @@ function debounce<T extends (...args: any[]) => void>(
195
134
  */
196
135
  export type DialogSize = 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'auto';
197
136
 
137
+ /**
138
+ * Dialog context value
139
+ */
140
+ interface DialogContextValue {
141
+ open: boolean;
142
+ onOpenChange: (open: boolean) => void;
143
+ dialogRef: React.RefObject<HTMLDialogElement | null>;
144
+ titleId: string;
145
+ descriptionId: string;
146
+ dialogTitle?: string; // For persistence key derivation
147
+ markClosedByUser?: () => void; // Callback to mark dialog as closed by user (for Cancel buttons, etc.)
148
+ }
149
+
150
+ const DialogContext = React.createContext<DialogContextValue | null>(null);
151
+
152
+ /**
153
+ * Hook to access Dialog context
154
+ */
155
+ function useDialogContext(): DialogContextValue {
156
+ const context = React.useContext(DialogContext);
157
+ if (!context) {
158
+ throw new Error('Dialog components must be used within a Dialog');
159
+ }
160
+ return context;
161
+ }
162
+
198
163
  /**
199
164
  * Props for the Dialog root component
200
165
  * @public
201
166
  */
202
- export interface DialogProps extends DialogPrimitive.DialogProps {}
167
+ export interface DialogProps {
168
+ children: React.ReactNode;
169
+ open?: boolean;
170
+ defaultOpen?: boolean;
171
+ onOpenChange?: (open: boolean) => void;
172
+ }
203
173
 
204
174
  /**
205
175
  * Props for the DialogTrigger component
206
176
  * @public
207
177
  */
208
- export interface DialogTriggerProps extends DialogPrimitive.DialogTriggerProps {}
178
+ export interface DialogTriggerProps {
179
+ children: React.ReactNode;
180
+ asChild?: boolean;
181
+ className?: string;
182
+ onClick?: (e: React.MouseEvent) => void;
183
+ }
209
184
 
210
185
  /**
211
186
  * Enhanced props for the DialogContent component with size variants and customization
212
- * Uses semantic HTML dialog element with Radix UI accessibility features
187
+ * Uses semantic HTML dialog element
213
188
  * @public
214
189
  */
215
- export interface DialogContentProps extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
190
+ export interface DialogContentProps extends React.HTMLAttributes<HTMLDialogElement> {
216
191
  /** Dialog size variant */
217
192
  size?: DialogSize;
218
193
  /** Whether to show the close button */
219
194
  showCloseButton?: boolean;
220
- /** Custom className for the overlay */
221
- overlayClassName?: string;
222
195
  /** Whether to prevent closing on escape key */
223
196
  preventCloseOnEscape?: boolean;
224
197
  /** Whether to prevent closing on outside click */
@@ -237,13 +210,33 @@ export interface DialogContentProps extends React.ComponentPropsWithoutRef<typeo
237
210
  minHeight?: string;
238
211
  /** Minimum width in CSS units */
239
212
  minWidth?: string;
213
+ /** Dialog title for accessibility (sets native title attribute) */
214
+ title?: string;
215
+ /** Dialog description for accessibility (sets aria-description attribute) */
216
+ description?: string;
217
+ /** Whether to persist open state across tab switches */
218
+ persistOpenState?: boolean;
240
219
  }
241
220
 
242
221
  /**
243
222
  * Props for the DialogOverlay component
244
223
  * @public
245
224
  */
246
- export interface DialogOverlayProps extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> {}
225
+ export interface DialogOverlayProps extends React.HTMLAttributes<HTMLDivElement> {}
226
+
227
+ /**
228
+ * Props for the DialogPortal component
229
+ * @public
230
+ */
231
+ export interface DialogPortalProps {
232
+ children: React.ReactNode;
233
+ }
234
+
235
+ /**
236
+ * Props for the DialogClose component
237
+ * @public
238
+ */
239
+ export interface DialogCloseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
247
240
 
248
241
  /**
249
242
  * Props for the DialogHeader component (semantic header element)
@@ -284,7 +277,7 @@ export interface DialogBodyProps extends React.HTMLAttributes<HTMLElement> {
284
277
  * Props for the DialogTitle component
285
278
  * @public
286
279
  */
287
- export interface DialogTitleProps extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> {
280
+ export interface DialogTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
288
281
  /** HTML content to render as title (will be sanitized for security) */
289
282
  htmlContent?: string;
290
283
  /** Whether to allow HTML content rendering (default: true) */
@@ -295,7 +288,7 @@ export interface DialogTitleProps extends React.ComponentPropsWithoutRef<typeof
295
288
  * Props for the DialogDescription component
296
289
  * @public
297
290
  */
298
- export interface DialogDescriptionProps extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> {
291
+ export interface DialogDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
299
292
  /** HTML content to render as description (will be sanitized for security) */
300
293
  htmlContent?: string;
301
294
  /** Whether to allow HTML content rendering (default: true) */
@@ -312,27 +305,144 @@ const sizeClasses = {
312
305
  auto: 'max-w-none w-auto min-w-0'
313
306
  };
314
307
 
315
- // Root Dialog components from Radix
316
- const Dialog = DialogPrimitive.Root;
317
- const DialogTrigger = DialogPrimitive.Trigger;
318
- const DialogPortal = DialogPrimitive.Portal;
319
- const DialogClose = DialogPrimitive.Close;
320
-
321
- // DialogOverlay component
322
- const DialogOverlay = React.forwardRef<
323
- React.ElementRef<typeof DialogPrimitive.Overlay>,
324
- DialogOverlayProps
325
- >(({ className, ...props }, ref) => (
326
- <DialogPrimitive.Overlay
327
- ref={ref}
328
- className={cn(
329
- 'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
330
- className
331
- )}
332
- {...props}
333
- />
334
- ));
335
- DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
308
+ /**
309
+ * Dialog root component
310
+ * Provides context for dialog state management
311
+ */
312
+ const Dialog = React.memo<DialogProps>(function Dialog({
313
+ children,
314
+ open: controlledOpen,
315
+ defaultOpen = false,
316
+ onOpenChange,
317
+ }) {
318
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
319
+ const dialogRef = useRef<HTMLDialogElement | null>(null);
320
+ const titleId = useId();
321
+ const descriptionId = useId();
322
+ const dialogTitleRef = useRef<string | undefined>(undefined);
323
+ const [markClosedByUser, setMarkClosedByUserState] = useState<(() => void) | undefined>(undefined);
324
+
325
+ const isControlled = controlledOpen !== undefined;
326
+ const open = isControlled ? controlledOpen : internalOpen;
327
+
328
+ const handleOpenChange = useCallback((newOpen: boolean) => {
329
+ if (!isControlled) {
330
+ setInternalOpen(newOpen);
331
+ }
332
+ onOpenChange?.(newOpen);
333
+ }, [isControlled, onOpenChange]);
334
+
335
+ const contextValue = React.useMemo<DialogContextValue>(() => ({
336
+ open,
337
+ onOpenChange: handleOpenChange,
338
+ dialogRef,
339
+ titleId,
340
+ descriptionId,
341
+ dialogTitle: dialogTitleRef.current,
342
+ markClosedByUser, // Set by DialogContent
343
+ }), [open, handleOpenChange, titleId, descriptionId, markClosedByUser]);
344
+
345
+ // Expose function to set dialog title (called by DialogContent)
346
+ const setDialogTitle = useCallback((title: string | undefined) => {
347
+ dialogTitleRef.current = title;
348
+ }, []);
349
+
350
+ // Expose function to set markClosedByUser callback (called by DialogContent)
351
+ const setMarkClosedByUser = useCallback((callback: (() => void) | undefined) => {
352
+ setMarkClosedByUserState(callback);
353
+ }, []);
354
+
355
+ return (
356
+ <DialogContext.Provider value={contextValue}>
357
+ <DialogTitleContext.Provider value={setDialogTitle}>
358
+ <DialogMarkClosedContext.Provider value={setMarkClosedByUser}>
359
+ {children}
360
+ </DialogMarkClosedContext.Provider>
361
+ </DialogTitleContext.Provider>
362
+ </DialogContext.Provider>
363
+ );
364
+ });
365
+
366
+ // Context for setting dialog title from DialogContent
367
+ const DialogTitleContext = React.createContext<((title: string | undefined) => void) | null>(null);
368
+
369
+ // Context for setting markClosedByUser callback from DialogContent
370
+ const DialogMarkClosedContext = React.createContext<((callback: (() => void) | undefined) => void) | null>(null);
371
+
372
+ // Context for marking dialog as closed by user (from DialogContent to DialogClose)
373
+ const DialogCloseContext = React.createContext<(() => void) | null>(null);
374
+ Dialog.displayName = 'Dialog';
375
+
376
+ /**
377
+ * DialogTrigger component
378
+ * Opens the dialog when clicked
379
+ */
380
+ const DialogTrigger = React.forwardRef<HTMLElement, DialogTriggerProps>(
381
+ ({ children, asChild = false, className, onClick, ...props }, ref) => {
382
+ const { onOpenChange } = useDialogContext();
383
+
384
+ const handleClick = useCallback((e: React.MouseEvent) => {
385
+ onClick?.(e);
386
+ onOpenChange(true);
387
+ }, [onOpenChange, onClick]);
388
+
389
+ if (asChild && React.isValidElement(children)) {
390
+ return React.cloneElement(children as React.ReactElement<any>, {
391
+ ref,
392
+ onClick: handleClick,
393
+ className: cn(className, (children as any).props?.className),
394
+ ...props,
395
+ });
396
+ }
397
+
398
+ return (
399
+ <button
400
+ ref={ref as React.RefObject<HTMLButtonElement>}
401
+ type="button"
402
+ onClick={handleClick}
403
+ className={className}
404
+ {...props}
405
+ >
406
+ {children}
407
+ </button>
408
+ );
409
+ }
410
+ );
411
+ DialogTrigger.displayName = 'DialogTrigger';
412
+
413
+ /**
414
+ * DialogPortal component
415
+ * Portals dialog content to document.body
416
+ */
417
+ const DialogPortal: React.FC<DialogPortalProps> = ({ children }) => {
418
+ const [mounted, setMounted] = useState(false);
419
+
420
+ useEffect(() => {
421
+ setMounted(true);
422
+ return () => setMounted(false);
423
+ }, []);
424
+
425
+ if (!mounted) return null;
426
+
427
+ return createPortal(children, document.body);
428
+ };
429
+ DialogPortal.displayName = 'DialogPortal';
430
+
431
+ /**
432
+ * DialogOverlay component
433
+ * Backdrop overlay for the dialog (optional, native dialog provides ::backdrop)
434
+ * This component is kept for backward compatibility but may not be needed
435
+ * when using native dialog element which provides ::backdrop automatically
436
+ */
437
+ const DialogOverlay = React.forwardRef<HTMLDivElement, DialogOverlayProps>(
438
+ ({ className, ...props }, ref) => {
439
+ // Note: Native dialog element provides ::backdrop automatically
440
+ // This component is kept for API compatibility but may not render
441
+ // The native dialog's ::backdrop is styled via CSS
442
+ return null;
443
+ }
444
+ );
445
+ DialogOverlay.displayName = 'DialogOverlay';
336
446
 
337
447
  /**
338
448
  * Custom hook for managing smart dialog dimensions
@@ -367,7 +477,7 @@ const useSmartDimensions = ({
367
477
 
368
478
  // Handle height constraints
369
479
  if (maxHeightPercent && typeof maxHeightPercent === 'number') {
370
- const constrainedHeight = Math.min(maxHeightPercent, 95); // Cap at 95% to ensure some margin
480
+ const constrainedHeight = Math.min(maxHeightPercent, 95);
371
481
  result.maxHeight = `${constrainedHeight}vh`;
372
482
  } else if (maxHeight) {
373
483
  result.maxHeight = maxHeight;
@@ -375,7 +485,7 @@ const useSmartDimensions = ({
375
485
 
376
486
  // Handle width constraints
377
487
  if (maxWidthPercent && typeof maxWidthPercent === 'number') {
378
- const constrainedWidth = Math.min(maxWidthPercent, 95); // Cap at 95% to ensure some margin
488
+ const constrainedWidth = Math.min(maxWidthPercent, 95);
379
489
  result.maxWidth = `${constrainedWidth}vw`;
380
490
  } else if (maxWidth) {
381
491
  result.maxWidth = maxWidth;
@@ -407,7 +517,7 @@ const useSmartDimensions = ({
407
517
  };
408
518
  }, [maxHeightPercent, maxWidthPercent, maxHeight, maxWidth, minHeight, minWidth, enableScrolling]);
409
519
 
410
- // Only return dimensions if we have something to constrain
520
+ // Return dimensions
411
521
  const result: React.CSSProperties = {};
412
522
 
413
523
  // Handle height constraints
@@ -437,149 +547,864 @@ const useSmartDimensions = ({
437
547
  return result;
438
548
  };
439
549
 
550
+ /**
551
+ * Global lock to ensure only one dialog is open at a time
552
+ * Uses sessionStorage for persistence across page reloads
553
+ */
554
+ const DIALOG_LOCK_KEY = 'pace-core:dialog:lock';
555
+
556
+ function acquireDialogLock(persistenceKey: string | null): boolean {
557
+ if (!persistenceKey) {
558
+ return true; // Non-persisted dialogs can always open
559
+ }
560
+
561
+ try {
562
+ const lock = sessionStorage.getItem(DIALOG_LOCK_KEY);
563
+ if (lock) {
564
+ const lockData = JSON.parse(lock);
565
+ // If lock is held by this dialog, allow it
566
+ if (lockData.key === persistenceKey) {
567
+ return true;
568
+ }
569
+ // If lock is held by another dialog, check if that dialog is still open
570
+ const lockDialog = document.querySelector(`dialog[data-persistence-key="${lockData.key}"]`) as HTMLDialogElement;
571
+ if (lockDialog && lockDialog.open) {
572
+ return false; // Another dialog is still open
573
+ }
574
+ // Lock is stale, clear it
575
+ sessionStorage.removeItem(DIALOG_LOCK_KEY);
576
+ }
577
+ // Acquire the lock
578
+ sessionStorage.setItem(DIALOG_LOCK_KEY, JSON.stringify({
579
+ key: persistenceKey,
580
+ timestamp: Date.now(),
581
+ }));
582
+ return true;
583
+ } catch {
584
+ // If sessionStorage fails, allow opening (graceful degradation)
585
+ return true;
586
+ }
587
+ }
588
+
589
+ function releaseDialogLock(persistenceKey: string | null): void {
590
+ if (!persistenceKey) {
591
+ return;
592
+ }
593
+
594
+ try {
595
+ const lock = sessionStorage.getItem(DIALOG_LOCK_KEY);
596
+ if (lock) {
597
+ const lockData = JSON.parse(lock);
598
+ if (lockData.key === persistenceKey) {
599
+ sessionStorage.removeItem(DIALOG_LOCK_KEY);
600
+ }
601
+ }
602
+ } catch {
603
+ // Ignore errors
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Check if any other dialog (besides the current one) has persisted open state
609
+ * This helps determine if another dialog should be allowed to auto-open
610
+ */
611
+ function checkOtherDialogsHavePersistedState(currentPersistenceKey: string | null): boolean {
612
+ if (!currentPersistenceKey) {
613
+ return false;
614
+ }
615
+
616
+ try {
617
+ const lock = sessionStorage.getItem(DIALOG_LOCK_KEY);
618
+ if (lock) {
619
+ const lockData = JSON.parse(lock);
620
+ if (lockData.key !== currentPersistenceKey) {
621
+ // Another dialog holds the lock
622
+ return true;
623
+ }
624
+ }
625
+ } catch {
626
+ // Error accessing sessionStorage - assume no other dialogs
627
+ return false;
628
+ }
629
+
630
+ return false;
631
+ }
632
+
440
633
  /**
441
634
  * DialogContent component
442
635
  * The main content container using semantic HTML <dialog> element with enhanced features
443
- * Built on Radix UI primitives for accessibility while providing semantic structure
444
636
  *
445
637
  * @param props - Content configuration and styling
446
638
  * @param ref - Forwarded ref to the dialog element
447
639
  * @returns JSX.Element - The semantic dialog content with overlay and optional close button
448
- *
449
- * @example
450
- * ```tsx
451
- * <DialogContent size="lg" enableScrolling={true} maxHeightPercent={80}>
452
- * <DialogHeader>
453
- * <DialogTitle>Scrollable Dialog</DialogTitle>
454
- * <DialogDescription>This dialog will scroll if content overflows.</DialogDescription>
455
- * </DialogHeader>
456
- * <DialogBody>
457
- * <section>Large amount of semantic content here...</section>
458
- * </DialogBody>
459
- * <DialogFooter>
460
- * <Button>Save</Button>
461
- * </DialogFooter>
462
- * </DialogContent>
463
- * ```
464
640
  */
465
- const DialogContent = React.forwardRef<
466
- React.ElementRef<typeof DialogPrimitive.Content>,
467
- DialogContentProps
468
- >(({
469
- className,
470
- children,
471
- size = 'md',
472
- showCloseButton = true,
473
- overlayClassName,
474
- preventCloseOnEscape = false,
475
- preventCloseOnOutsideClick = false,
476
- maxHeightPercent,
477
- maxWidthPercent,
478
- enableScrolling = false,
479
- maxHeight,
480
- maxWidth,
481
- minHeight,
482
- minWidth,
483
- style,
484
- ...props
485
- }, ref) => {
486
- const smartDimensions = useSmartDimensions({
487
- maxHeightPercent,
641
+ const DialogContent = React.forwardRef<HTMLDialogElement, DialogContentProps>(
642
+ ({
643
+ className,
644
+ children,
645
+ size = 'md',
646
+ showCloseButton = true,
647
+ preventCloseOnEscape = false,
648
+ preventCloseOnOutsideClick = false,
649
+ maxHeightPercent,
488
650
  maxWidthPercent,
489
- maxHeight,
651
+ enableScrolling = false,
652
+ maxHeight,
490
653
  maxWidth,
491
654
  minHeight,
492
655
  minWidth,
493
- enableScrolling
494
- });
656
+ title,
657
+ description,
658
+ style,
659
+ persistOpenState = true,
660
+ ...props
661
+ }, ref) => {
662
+ // Call all hooks unconditionally at the top level
663
+ // Hooks must be called in the same order on every render
664
+ const { open, onOpenChange, dialogRef, titleId, descriptionId } = useDialogContext();
665
+ const setDialogTitle = React.useContext(DialogTitleContext);
666
+ const setMarkClosedByUser = React.useContext(DialogMarkClosedContext);
667
+
668
+ // Component mount/unmount tracking removed for performance
495
669
 
496
- // React Compiler handles memoization automatically
497
- const handleEscapeKeyDown = (event: KeyboardEvent) => {
498
- if (preventCloseOnEscape) {
499
- event.preventDefault();
500
- }
501
- };
670
+ // Call hooks unconditionally - if providers are missing, they will throw
671
+ // Errors should be handled by error boundaries at a higher level
672
+ const location = useLocation();
673
+ const auth = useUnifiedAuth();
674
+ const userId = auth.user?.id || null;
502
675
 
503
- const handlePointerDownOutside = (event: Event) => {
504
- if (preventCloseOnOutsideClick) {
505
- event.preventDefault();
506
- }
507
- };
676
+ // Set dialog title in context for persistence
677
+ useEffect(() => {
678
+ if (setDialogTitle) {
679
+ setDialogTitle(title);
680
+ }
681
+ }, [title, setDialogTitle]);
508
682
 
509
- // Merge smart dimensions with provided style
510
- const mergedStyle = React.useMemo(() => {
511
- // If no smart dimensions are active, just return the provided style
512
- if (Object.keys(smartDimensions).length === 0) {
513
- return style;
514
- }
683
+ // Derive persistence key (scoped by user ID)
684
+ // CRITICAL: Only enable persistence if we have a valid userId to prevent data leakage
685
+ const persistenceKey = useMemo(() => {
686
+ if (!persistOpenState) {
687
+ return null;
688
+ }
689
+ // Don't create persistence key if userId is not available
690
+ // This prevents unscoped persistence that could leak between users
691
+ if (!userId) {
692
+ return null;
693
+ }
694
+ return deriveDialogKey(
695
+ {
696
+ title,
697
+ description,
698
+ },
699
+ location,
700
+ userId
701
+ );
702
+ }, [title, description, location, userId, persistOpenState]);
703
+
704
+ // Use session draft for open state persistence
705
+ // Only enabled when we have a valid persistenceKey (which requires userId)
706
+ const { state: persistedOpen, setState: setPersistedOpen, clearDraft, wasRestored } = useSessionDraft<boolean>(
707
+ persistenceKey ? `${persistenceKey}:open` : 'dialog:no-key:open',
708
+ false,
709
+ {
710
+ enabled: Boolean(persistenceKey && persistOpenState && userId),
711
+ debounceMs: 300,
712
+ }
713
+ );
515
714
 
516
- // Start with smart dimensions
517
- const finalStyle: React.CSSProperties = { ...smartDimensions, ...style };
715
+ // Track if we've attempted auto-open to prevent multiple attempts
716
+ const hasAutoOpenedRef = useRef(false);
717
+ const hasInitializedRef = useRef(false);
718
+ // Track if dialog was closed by user action (to clear persistence)
719
+ const wasClosedByUserRef = useRef(false);
720
+ // Track if dialog was manually opened (to prevent auto-open from interfering)
721
+ const wasManuallyOpenedRef = useRef(false);
518
722
 
519
- // If not using smart width and no maxWidth override, don't include maxWidth
520
- if (!maxWidth && !maxWidthPercent) {
521
- const { maxWidth: _, ...styleWithoutMaxWidth } = finalStyle;
522
- return styleWithoutMaxWidth;
523
- }
723
+ // Callback to mark dialog as closed by user (exposed via context for DialogClose and Cancel buttons)
724
+ const markClosedByUser = useCallback(() => {
725
+ if (hasInitializedRef.current) {
726
+ wasClosedByUserRef.current = true;
727
+ }
728
+ }, []);
729
+
730
+ // Register markClosedByUser with parent Dialog component so it's available via DialogContext
731
+ useEffect(() => {
732
+ if (setMarkClosedByUser) {
733
+ setMarkClosedByUser(markClosedByUser);
734
+ }
735
+ return () => {
736
+ // Cleanup: unregister when DialogContent unmounts
737
+ if (setMarkClosedByUser) {
738
+ setMarkClosedByUser(undefined);
739
+ }
740
+ };
741
+ }, [setMarkClosedByUser, markClosedByUser]);
742
+ // Track if we've cleaned up other dialog states (to prevent multiple cleanup runs)
743
+ const hasCleanedUpOtherDialogsRef = useRef(false);
744
+
745
+ // Auto-open on mount if dialog was open when tab closed
746
+ useEffect(() => {
747
+ if (!persistenceKey || !persistOpenState) {
748
+ hasInitializedRef.current = true;
749
+ return;
750
+ }
751
+
752
+ // Only attempt auto-open once
753
+ // This prevents auto-open from running after manual opens
754
+ if (hasAutoOpenedRef.current) {
755
+ return;
756
+ }
757
+
758
+ // CRITICAL: Don't auto-open if dialog was manually opened
759
+ // This prevents auto-open from interfering with manual opens
760
+ if (wasManuallyOpenedRef.current) {
761
+ console.log('[Dialog Persistence] ⏭️ Skipping auto-open - dialog was manually opened', {
762
+ persistenceKey,
763
+ currentOpen: open,
764
+ });
765
+ return;
766
+ }
767
+
768
+ // Mark as initialized after first check
769
+ if (!hasInitializedRef.current) {
770
+ hasInitializedRef.current = true;
771
+ }
772
+
773
+ // Auto-open check (logging removed for performance)
774
+
775
+ // CRITICAL: Don't auto-open if dialog is already open (user-initiated)
776
+ if (open === true) {
777
+ hasAutoOpenedRef.current = true;
778
+ return;
779
+ }
780
+
781
+ // CRITICAL: Don't auto-open if userId is not available (prevents unscoped persistence from being restored)
782
+ if (!userId) {
783
+ hasAutoOpenedRef.current = true;
784
+ return;
785
+ }
786
+
787
+ // Only auto-open if conditions are met
788
+ if (persistedOpen === true && open === false && wasRestored && !hasAutoOpenedRef.current && !wasManuallyOpenedRef.current) {
789
+ const AUTO_OPEN_LOCK_KEY = 'pace-core:dialog:auto-open-lock';
790
+ const lockTimestamp = sessionStorage.getItem(AUTO_OPEN_LOCK_KEY);
791
+ const now = Date.now();
792
+
793
+ // Check if another dialog is already auto-opening (lock exists and is recent)
794
+ if (lockTimestamp) {
795
+ const lockAge = now - parseInt(lockTimestamp, 10);
796
+ if (lockAge < 1000) {
797
+ // Lock is recent - another dialog is auto-opening
798
+ // Check if there's already an open dialog with this persistence key
799
+ const existingOpenDialog = document.querySelector(`dialog[data-persistence-key="${persistenceKey}"][open]`);
800
+ if (existingOpenDialog) {
801
+ // Another instance is already open - skip
802
+ hasAutoOpenedRef.current = true;
803
+ return;
804
+ }
805
+ // Check if other dialog has persisted state
806
+ const otherDialogHasPersistedState = checkOtherDialogsHavePersistedState(persistenceKey);
807
+ if (otherDialogHasPersistedState) {
808
+ // Another dialog with persisted state is auto-opening - skip this one
809
+ clearDraft();
810
+ hasAutoOpenedRef.current = true;
811
+ return;
812
+ }
813
+ }
814
+ // Clear stale locks
815
+ if (lockAge > 2000) {
816
+ sessionStorage.removeItem(AUTO_OPEN_LOCK_KEY);
817
+ }
818
+ }
819
+
820
+ // Check if dialog with same key is already open in DOM (synchronous check)
821
+ const existingDialog = document.querySelector(`dialog[data-persistence-key="${persistenceKey}"][open]`);
822
+ if (existingDialog && existingDialog !== dialogRef.current && existingDialog !== internalRef.current) {
823
+ hasAutoOpenedRef.current = true;
824
+ return;
825
+ }
826
+
827
+ // Set lock and mark as auto-opened BEFORE calling onOpenChange
828
+ sessionStorage.setItem(AUTO_OPEN_LOCK_KEY, String(now));
829
+ hasAutoOpenedRef.current = true;
830
+ wasManuallyOpenedRef.current = false;
831
+
832
+ console.log('[Dialog] 🔄 AUTO-OPEN', { persistenceKey });
833
+
834
+ // Use small delay to prevent visual flash
835
+ const timeoutId = setTimeout(() => {
836
+ // Double-check: if dialog is still closed and no other instance opened it
837
+ const stillClosed = !open;
838
+ const noOtherInstance = !document.querySelector(`dialog[data-persistence-key="${persistenceKey}"][open]`);
839
+
840
+ if (stillClosed && noOtherInstance) {
841
+ sessionStorage.removeItem(AUTO_OPEN_LOCK_KEY);
842
+ onOpenChange(true);
843
+ } else {
844
+ sessionStorage.removeItem(AUTO_OPEN_LOCK_KEY);
845
+ }
846
+ }, 75);
847
+
848
+ return () => {
849
+ clearTimeout(timeoutId);
850
+ sessionStorage.removeItem(AUTO_OPEN_LOCK_KEY);
851
+ };
852
+ }
853
+ }, [persistenceKey, persistOpenState, persistedOpen, open, onOpenChange, wasRestored, clearDraft]);
854
+
855
+ // When this dialog auto-opens, clear persisted state of all other dialogs
856
+ // This prevents multiple dialogs from being restored simultaneously
857
+ useEffect(() => {
858
+ if (!persistenceKey || !persistOpenState || !open || !hasAutoOpenedRef.current) {
859
+ return;
860
+ }
861
+
862
+ // Only run once when dialog first auto-opens
863
+ if (hasCleanedUpOtherDialogsRef.current) {
864
+ return;
865
+ }
866
+
867
+ // Clear all other dialog persisted states from sessionStorage
868
+ // AND close any other dialogs that are currently open in the DOM
869
+ // This ensures only the first auto-opened dialog remains open
870
+ try {
871
+ const keysToRemove: string[] = [];
872
+ for (let i = 0; i < sessionStorage.length; i++) {
873
+ const key = sessionStorage.key(i);
874
+ if (key && key.startsWith('pace-core:draft:dialog:') && key.endsWith(':open')) {
875
+ // Don't clear this dialog's own state
876
+ if (key !== `pace-core:draft:${persistenceKey}:open`) {
877
+ keysToRemove.push(key);
878
+ }
879
+ }
880
+ }
881
+
882
+ if (keysToRemove.length > 0) {
883
+ console.log('[Dialog Persistence] Clearing other dialog persisted states:', keysToRemove);
884
+ keysToRemove.forEach(key => sessionStorage.removeItem(key));
885
+ }
886
+
887
+ // Also close any other dialogs that are currently open in the DOM AND have persisted state
888
+ // This prevents dialogs with persisted state from being hidden behind this one
889
+ // We only close dialogs that have persisted state, not dialogs opened by app code
890
+ // We identify dialogs with persistence using a data attribute
891
+ const timeoutId = setTimeout(() => {
892
+ // Guard against test environment teardown
893
+ if (typeof document === 'undefined' || typeof sessionStorage === 'undefined') {
894
+ return;
895
+ }
896
+
897
+ const otherOpenDialogs = document.querySelectorAll('dialog[open][role="dialog"]');
898
+ const currentDialog = dialogRef.current || internalRef.current;
899
+ if (otherOpenDialogs.length > 0 && currentDialog) {
900
+ let closedCount = 0;
901
+ otherOpenDialogs.forEach((dialog) => {
902
+ // Don't close this dialog
903
+ const dialogElement = dialog as HTMLDialogElement;
904
+ if (dialogElement !== currentDialog) {
905
+ // Check if this dialog has a data-persistence-key attribute
906
+ // This indicates it was auto-opened from persistence
907
+ const dialogPersistenceKey = dialogElement.getAttribute('data-persistence-key');
908
+ if (dialogPersistenceKey && dialogPersistenceKey !== persistenceKey) {
909
+ // Check if this dialog's persisted state is true
910
+ let hasPersistedState = false;
911
+ try {
912
+ const key = `pace-core:draft:${dialogPersistenceKey}:open`;
913
+ const stored = sessionStorage.getItem(key);
914
+ if (stored) {
915
+ const parsed = JSON.parse(stored);
916
+ if (parsed && parsed.data === true) {
917
+ hasPersistedState = true;
918
+ }
919
+ }
920
+ } catch {
921
+ // Invalid data - skip
922
+ }
923
+
924
+ // Only close if this dialog has persisted state (was auto-opened)
925
+ if (hasPersistedState) {
926
+ console.log('[Dialog Persistence] Closing other dialog with persisted state:', dialogPersistenceKey);
927
+ dialogElement.close();
928
+ closedCount++;
929
+ }
930
+ }
931
+ // If dialog doesn't have data-persistence-key, it was opened by app code - don't close it
932
+ }
933
+ });
934
+ if (closedCount > 0) {
935
+ console.log('[Dialog Persistence] Closed', closedCount, 'other dialog(s) with persisted state');
936
+ }
937
+ }
938
+ }, 100);
939
+
940
+ hasCleanedUpOtherDialogsRef.current = true;
941
+
942
+ // Also clear the auto-open lock
943
+ if (typeof sessionStorage !== 'undefined') {
944
+ sessionStorage.removeItem('pace-core:dialog:auto-open-lock');
945
+ }
946
+
947
+ // Cleanup timeout on unmount or dependency change
948
+ return () => {
949
+ clearTimeout(timeoutId);
950
+ };
951
+ } catch (error) {
952
+ console.warn('[Dialog Persistence] Failed to clear other dialog states:', error);
953
+ }
954
+ }, [open, persistenceKey, persistOpenState]);
955
+
956
+ // When dialog closes (user action), immediately clear persisted state
957
+ // This prevents the dialog from auto-opening again after user explicitly closed it
958
+ useEffect(() => {
959
+ if (!persistenceKey || !persistOpenState) {
960
+ return;
961
+ }
962
+
963
+ if (!hasInitializedRef.current) {
964
+ return;
965
+ }
966
+
967
+ // If dialog is closed and user closed it, clear persisted state immediately
968
+ if (!open && wasClosedByUserRef.current) {
969
+ clearDraft();
970
+ wasClosedByUserRef.current = false;
971
+ }
972
+ }, [open, persistenceKey, persistOpenState, clearDraft]);
973
+
974
+ // Check lock BEFORE allowing dialog to open (synchronous check)
975
+ // This prevents React from even trying to open if another dialog is open
976
+ useEffect(() => {
977
+ if (!open) {
978
+ return;
979
+ }
980
+
981
+ // Synchronously check if we can acquire the lock
982
+ const lockAcquired = acquireDialogLock(persistenceKey);
983
+ if (!lockAcquired) {
984
+ // Another dialog is open - prevent this one from opening
985
+ console.warn('[Dialog] ⚠️ Cannot open - another dialog holds the lock', {
986
+ persistenceKey,
987
+ });
988
+ // Immediately close this dialog's React state
989
+ onOpenChange?.(false);
990
+ return;
991
+ }
992
+
993
+ // Lock acquired successfully - dialog can proceed to open
994
+ }, [open, persistenceKey, onOpenChange]);
995
+
996
+ // Track when dialog closes via onOpenChange to mark as closed by user
997
+ // This handles Cancel buttons and other programmatic closes
998
+ const previousOpenRef = useRef(open);
999
+ useEffect(() => {
1000
+ // If dialog was open and is now closed, and it wasn't auto-opened, mark as closed by user
1001
+ if (previousOpenRef.current === true && open === false && hasInitializedRef.current) {
1002
+ // Only mark as closed by user if it wasn't an auto-open scenario
1003
+ // Auto-open sets hasAutoOpenedRef before calling onOpenChange, so we can detect it
1004
+ if (!hasAutoOpenedRef.current || wasManuallyOpenedRef.current) {
1005
+ // Dialog was manually opened and then closed - mark as closed by user
1006
+ wasClosedByUserRef.current = true;
1007
+ }
1008
+ }
1009
+ previousOpenRef.current = open;
1010
+ }, [open]);
1011
+
1012
+ // Persist open state changes
1013
+ useEffect(() => {
1014
+ if (!persistenceKey || !persistOpenState) {
1015
+ return;
1016
+ }
1017
+
1018
+ // Only persist after initial mount check is complete
1019
+ // This prevents overwriting the persisted state before auto-open can read it
1020
+ if (!hasInitializedRef.current) {
1021
+ return;
1022
+ }
1023
+
1024
+ // Persisting open state (logging removed for performance)
1025
+
1026
+ let logTimeoutId: ReturnType<typeof setTimeout> | null = null;
1027
+
1028
+ // Only persist when dialog is open
1029
+ if (open) {
1030
+ // Reset the flag when opening
1031
+ wasClosedByUserRef.current = false;
1032
+ // If dialog is manually opened (not via auto-open), mark it so auto-open doesn't interfere
1033
+ // This prevents auto-open from trying to open an already-open dialog
1034
+ // We check if hasAutoOpenedRef is false to determine if this is a manual open
1035
+ // (auto-open sets hasAutoOpenedRef to true before calling onOpenChange)
1036
+ if (!hasAutoOpenedRef.current) {
1037
+ // Mark as manually opened to prevent auto-open from interfering
1038
+ wasManuallyOpenedRef.current = true;
1039
+ // Also mark as "opened" to prevent auto-open from trying to open it again
1040
+ hasAutoOpenedRef.current = true;
1041
+ }
1042
+ setPersistedOpen(true);
1043
+ } else {
1044
+ // Only clear draft if user explicitly closed (not if it was never opened or auto-opened then closed)
1045
+ if (wasClosedByUserRef.current) {
1046
+ clearDraft();
1047
+ wasClosedByUserRef.current = false;
1048
+ // Reset manual open flag when dialog is closed
1049
+ wasManuallyOpenedRef.current = false;
1050
+ }
1051
+ }
1052
+
1053
+ // Cleanup timeout on unmount or dependency change
1054
+ return () => {
1055
+ if (logTimeoutId) {
1056
+ clearTimeout(logTimeoutId);
1057
+ }
1058
+ };
1059
+ }, [open, persistenceKey, persistOpenState, setPersistedOpen, clearDraft]);
1060
+
1061
+ // Note: We do NOT automatically clear the draft when dialog closes
1062
+ // The draft should only be cleared on explicit user actions (e.g., form submit success)
1063
+ // This allows the dialog to restore its state after tab switches
1064
+ const internalRef = useRef<HTMLDialogElement>(null);
524
1065
 
525
- return finalStyle;
526
- }, [smartDimensions, style, maxWidth, maxWidthPercent]);
1066
+ // Use the dialogRef from context, or fall back to internal ref or forwarded ref
1067
+ const actualDialogRef = dialogRef.current ? dialogRef : (ref ? (ref as React.RefObject<HTMLDialogElement>) : internalRef);
527
1068
 
528
- return (
529
- <DialogPortal>
530
- <DialogOverlay className={overlayClassName} />
531
- <DialogPrimitive.Content
1069
+ // Default to 80% viewport height if no height constraint is provided
1070
+ // This allows the dialog to grow to 80% before enabling scrolling
1071
+ const effectiveMaxHeightPercent = maxHeightPercent ?? (maxHeight ? undefined : 80);
1072
+
1073
+ // Determine if we have a height constraint that requires flex layout
1074
+ const hasHeightConstraint = Boolean(effectiveMaxHeightPercent || maxHeight);
1075
+
1076
+ const smartDimensions = useSmartDimensions({
1077
+ maxHeightPercent: effectiveMaxHeightPercent,
1078
+ maxWidthPercent,
1079
+ maxHeight,
1080
+ maxWidth,
1081
+ minHeight,
1082
+ minWidth,
1083
+ enableScrolling
1084
+ });
1085
+
1086
+ // Focus trap
1087
+ const { containerRef } = useFocusTrap({
1088
+ isActive: open,
1089
+ autoFocus: true,
1090
+ restoreFocus: true,
1091
+ onEscape: preventCloseOnEscape ? undefined : () => onOpenChange(false),
1092
+ });
1093
+
1094
+ // Merge refs
1095
+ const mergedRef = useCallback((node: HTMLDialogElement | null) => {
1096
+ // Set context dialog ref
1097
+ if (dialogRef && 'current' in dialogRef) {
1098
+ (dialogRef as React.MutableRefObject<HTMLDialogElement | null>).current = node;
1099
+ }
1100
+ // Set internal ref
1101
+ if (internalRef && 'current' in internalRef) {
1102
+ internalRef.current = node;
1103
+ }
1104
+ // Set focus trap container ref
1105
+ if (containerRef && 'current' in containerRef) {
1106
+ (containerRef as React.MutableRefObject<HTMLElement | null>).current = node;
1107
+ }
1108
+ // Handle forwarded ref
1109
+ if (typeof ref === 'function') {
1110
+ ref(node);
1111
+ } else if (ref && 'current' in ref) {
1112
+ (ref as React.MutableRefObject<HTMLDialogElement | null>).current = node;
1113
+ }
1114
+ }, [dialogRef, containerRef, ref]);
1115
+
1116
+ // Handle dialog open/close
1117
+ useEffect(() => {
1118
+ const dialog = dialogRef.current || internalRef.current;
1119
+ if (!dialog) return;
1120
+
1121
+ if (open) {
1122
+ // Log all dialogs in DOM before opening
1123
+ const allDialogsBefore = document.querySelectorAll('dialog[role="dialog"]');
1124
+ const dialogsBefore = Array.from(allDialogsBefore).map((d) => {
1125
+ const dialogEl = d as HTMLDialogElement;
1126
+ return {
1127
+ persistenceKey: dialogEl.getAttribute('data-persistence-key') || 'NO-KEY',
1128
+ open: dialogEl.open,
1129
+ isCurrent: d === dialog,
1130
+ };
1131
+ });
1132
+ console.log('[Dialog] 🟢 OPENING', {
1133
+ persistenceKey,
1134
+ dialogsInDOM: dialogsBefore,
1135
+ totalDialogs: allDialogsBefore.length,
1136
+ });
1137
+
1138
+ // Use requestAnimationFrame to ensure DOM is ready
1139
+ // Lock was already checked in the earlier useEffect, so we can proceed
1140
+ requestAnimationFrame(() => {
1141
+ if (dialog && open) {
1142
+ // Before opening, close any other open dialogs (safety check)
1143
+ const allDialogs = document.querySelectorAll('dialog[role="dialog"]');
1144
+ allDialogs.forEach((d) => {
1145
+ const dialogEl = d as HTMLDialogElement;
1146
+ if (dialogEl !== dialog && dialogEl.open) {
1147
+ dialogEl.setAttribute('data-duplicate-cleanup', 'true');
1148
+ dialogEl.close();
1149
+ }
1150
+ });
1151
+
1152
+ console.log('[Dialog] ✅ showModal() called', { persistenceKey });
1153
+ dialog.showModal();
1154
+ }
1155
+ });
1156
+ } else {
1157
+ // Close dialog before it's removed from DOM
1158
+ if (dialog.open) {
1159
+ console.log('[Dialog] 🔴 CLOSING', { persistenceKey });
1160
+ dialog.close();
1161
+ // Release the lock
1162
+ releaseDialogLock(persistenceKey);
1163
+
1164
+ // After closing, check if any other dialogs with persistence are trying to open
1165
+ // Only close dialogs that have persistence (data-persistence-key attribute)
1166
+ // Non-persistent dialogs should be left alone
1167
+ setTimeout(() => {
1168
+ const allDialogs = document.querySelectorAll('dialog[role="dialog"]');
1169
+ allDialogs.forEach((d) => {
1170
+ const dialogEl = d as HTMLDialogElement;
1171
+ if (dialogEl !== dialog && dialogEl.open) {
1172
+ const otherPersistenceKey = dialogEl.getAttribute('data-persistence-key');
1173
+ // Only close dialogs that have persistence (they might auto-open)
1174
+ // Non-persistent dialogs are user-controlled and shouldn't be closed
1175
+ if (otherPersistenceKey) {
1176
+ console.warn('[Dialog] 🗑️ Closing other persisted dialog after lock release:', {
1177
+ persistenceKey,
1178
+ otherPersistenceKey,
1179
+ });
1180
+ dialogEl.setAttribute('data-duplicate-cleanup', 'true');
1181
+ dialogEl.close();
1182
+ }
1183
+ }
1184
+ });
1185
+ }, 50);
1186
+ }
1187
+ }
1188
+ }, [open, persistenceKey, dialogRef]);
1189
+
1190
+ // Handle close event - sync state when dialog is closed externally
1191
+ // Also track when dialog is closed by user action (for persistence clearing)
1192
+ useEffect(() => {
1193
+ const dialog = dialogRef.current || internalRef.current;
1194
+ if (!dialog) return;
1195
+
1196
+ const handleClose = () => {
1197
+ // Check if this close was initiated by the user (via close button)
1198
+ const wasUserClosed = dialog.hasAttribute('data-user-closed');
1199
+ if (wasUserClosed) {
1200
+ dialog.removeAttribute('data-user-closed');
1201
+ if (hasInitializedRef.current) {
1202
+ wasClosedByUserRef.current = true;
1203
+ }
1204
+ }
1205
+
1206
+ // Ignore duplicate cleanup closes
1207
+ const isDuplicateCleanup = dialog.hasAttribute('data-duplicate-cleanup');
1208
+ if (isDuplicateCleanup) {
1209
+ dialog.removeAttribute('data-duplicate-cleanup');
1210
+ return;
1211
+ }
1212
+
1213
+ if (!dialog.open && open) {
1214
+ // Mark as closed by user if this wasn't an auto-open scenario
1215
+ if (hasInitializedRef.current && !wasUserClosed) {
1216
+ wasClosedByUserRef.current = true;
1217
+ }
1218
+ onOpenChange(false);
1219
+ } else if (!dialog.open && !open && hasInitializedRef.current && wasUserClosed) {
1220
+ wasClosedByUserRef.current = true;
1221
+ }
1222
+ };
1223
+
1224
+ dialog.addEventListener('close', handleClose);
1225
+ return () => {
1226
+ dialog.removeEventListener('close', handleClose);
1227
+ };
1228
+ }, [open, onOpenChange, dialogRef]);
1229
+
1230
+ // Handle cancel event (Escape or backdrop click)
1231
+ useEffect(() => {
1232
+ const dialog = dialogRef.current || internalRef.current;
1233
+ if (!dialog) return;
1234
+
1235
+ const handleCancel = (e: Event) => {
1236
+ if (preventCloseOnEscape || preventCloseOnOutsideClick) {
1237
+ e.preventDefault();
1238
+ return;
1239
+ }
1240
+ // Mark as closed by user and clear persisted state
1241
+ wasClosedByUserRef.current = true;
1242
+ if (persistenceKey && persistOpenState && clearDraft) {
1243
+ clearDraft();
1244
+ }
1245
+ onOpenChange(false);
1246
+ };
1247
+
1248
+ dialog.addEventListener('cancel', handleCancel);
1249
+ return () => {
1250
+ dialog.removeEventListener('cancel', handleCancel);
1251
+ };
1252
+ }, [preventCloseOnEscape, preventCloseOnOutsideClick, onOpenChange, dialogRef, persistenceKey, persistOpenState, clearDraft]);
1253
+
1254
+ // Merge smart dimensions with provided style
1255
+ const mergedStyle = React.useMemo(() => {
1256
+ if (Object.keys(smartDimensions).length === 0) {
1257
+ return style;
1258
+ }
1259
+
1260
+ const finalStyle: React.CSSProperties = { ...smartDimensions, ...style };
1261
+
1262
+ if (!maxWidth && !maxWidthPercent) {
1263
+ const { maxWidth: _, ...styleWithoutMaxWidth } = finalStyle;
1264
+ return styleWithoutMaxWidth;
1265
+ }
1266
+
1267
+ return finalStyle;
1268
+ }, [smartDimensions, style, maxWidth, maxWidthPercent]);
1269
+
1270
+ // Track if lock has been acquired (set by useEffect when open becomes true)
1271
+ const [lockAcquired, setLockAcquired] = React.useState(false);
1272
+
1273
+ // Check lock BEFORE allowing dialog to open (synchronous check)
1274
+ // This prevents React from even trying to open if another dialog is open
1275
+ useEffect(() => {
1276
+ if (!open) {
1277
+ setLockAcquired(false);
1278
+ return;
1279
+ }
1280
+
1281
+ // Synchronously check if we can acquire the lock
1282
+ const acquired = acquireDialogLock(persistenceKey);
1283
+ setLockAcquired(acquired);
1284
+
1285
+ if (!acquired) {
1286
+ // Another dialog is open - prevent this one from opening
1287
+ console.warn('[Dialog] ⚠️ Cannot open - another dialog holds the lock', {
1288
+ persistenceKey,
1289
+ });
1290
+ // Immediately close this dialog's React state
1291
+ onOpenChange?.(false);
1292
+ return;
1293
+ }
1294
+
1295
+ // Lock acquired successfully - dialog can proceed to open
1296
+ }, [open, persistenceKey, onOpenChange]);
1297
+
1298
+ // Synchronously check if we can render (must hold lock if open)
1299
+ const canRender = React.useMemo(() => {
1300
+ if (!open) {
1301
+ return true; // Can always render when closed
1302
+ }
1303
+ if (!persistenceKey) {
1304
+ return true; // Non-persisted dialogs can always render
1305
+ }
1306
+ // Use the lockAcquired state which is set by the effect
1307
+ return lockAcquired;
1308
+ }, [open, persistenceKey, lockAcquired]);
1309
+
1310
+ return (
1311
+ <DialogPortal>
1312
+ {open && canRender && (
1313
+ <dialog
1314
+ ref={mergedRef}
1315
+ className={cn(
1316
+ 'fixed left-[50%] top-[50%] z-[51] w-full translate-x-[-50%] translate-y-[-50%] border bg-background shadow-lg duration-200',
1317
+ 'animate-in fade-in-0 zoom-in-95 slide-in-from-left-1/2 slide-in-from-top-[48%]',
1318
+ 'sm:rounded-lg',
1319
+ // Reset native dialog styles
1320
+ 'm-0 p-0 max-w-none max-h-none w-auto h-auto border-0 bg-transparent outline-none',
1321
+ // Apply our custom styling
1322
+ 'border bg-background shadow-lg',
1323
+ // Style native backdrop pseudo-element (Tailwind v4 supports arbitrary variants)
1324
+ '[&::backdrop]:bg-black/50 [&::backdrop]:animate-in [&::backdrop]:fade-in-0',
1325
+ // Only apply size classes if not using smart width
1326
+ !maxWidth && !maxWidthPercent && sizeClasses[size],
1327
+ // Auto size gets special handling
1328
+ size === 'auto' && 'w-fit max-w-[90vw] sm:max-w-[80vw]',
1329
+ // Layout classes: use flex when we have height constraints or enableScrolling is true
1330
+ // Flex layout is needed for proper scrolling when height is constrained
1331
+ (enableScrolling || hasHeightConstraint) ? 'flex flex-col px-6' : 'grid gap-4 p-6',
1332
+ // Full screen handling
1333
+ size === 'full' && 'sm:left-[50%] sm:top-[50%] sm:translate-x-[-50%] sm:translate-y-[-50%] left-0 top-0 translate-x-0 translate-y-0 h-full rounded-none sm:h-auto sm:rounded-lg',
1334
+ // Overflow handling for scrolling mode or when height is constrained
1335
+ (enableScrolling || hasHeightConstraint) && 'overflow-hidden',
1336
+ className
1337
+ )}
1338
+ style={mergedStyle}
1339
+ role="dialog"
1340
+ aria-modal="true"
1341
+ aria-labelledby={titleId}
1342
+ aria-describedby={descriptionId}
1343
+ title={title}
1344
+ aria-description={description}
1345
+ data-persistence-key={persistenceKey && persistOpenState ? persistenceKey : undefined}
1346
+ {...props}
1347
+ >
1348
+ <DialogCloseContext.Provider value={markClosedByUser}>
1349
+ {children}
1350
+ {showCloseButton && (
1351
+ <DialogClose />
1352
+ )}
1353
+ </DialogCloseContext.Provider>
1354
+ </dialog>
1355
+ )}
1356
+ </DialogPortal>
1357
+ );
1358
+ }
1359
+ );
1360
+ DialogContent.displayName = 'DialogContent';
1361
+
1362
+ /**
1363
+ * DialogClose component
1364
+ * Button to close the dialog
1365
+ */
1366
+ const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>(
1367
+ ({ className, onClick, ...props }, ref) => {
1368
+ // Call all hooks unconditionally at the top level
1369
+ // Hooks must be called in the same order on every render
1370
+ const { onOpenChange, markClosedByUser: contextMarkClosedByUser } = useDialogContext();
1371
+ // Prefer DialogContext markClosedByUser (available to all components), fallback to DialogCloseContext (for backwards compatibility)
1372
+ const dialogCloseContextValue = React.useContext(DialogCloseContext);
1373
+ const markClosedByUser = contextMarkClosedByUser || dialogCloseContextValue;
1374
+
1375
+ const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
1376
+ // Mark dialog as closed by user before calling onOpenChange
1377
+ // This ensures the persisted state is cleared when user clicks close button
1378
+ if (markClosedByUser) {
1379
+ markClosedByUser();
1380
+ }
1381
+
1382
+ onClick?.(e);
1383
+ onOpenChange(false);
1384
+ };
1385
+
1386
+ return (
1387
+ <button
532
1388
  ref={ref}
533
- onEscapeKeyDown={preventCloseOnEscape ? handleEscapeKeyDown : undefined}
534
- onPointerDownOutside={handlePointerDownOutside}
1389
+ type="button"
1390
+ onClick={handleClick}
535
1391
  className={cn(
536
- 'fixed left-[50%] top-[50%] z-[51] w-full translate-x-[-50%] translate-y-[-50%] border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
537
- // Reset native dialog styles that interfere with our custom styling
538
- 'm-0 p-0 max-w-none max-h-none w-auto h-auto border-0 bg-transparent outline-none',
539
- // Apply our custom styling
540
- 'border bg-background shadow-lg',
541
- // Only apply size classes if not using smart width
542
- !maxWidth && !maxWidthPercent && sizeClasses[size],
543
- // Auto size gets special handling for content fitting
544
- size === 'auto' && 'w-fit max-w-[90vw] sm:max-w-[80vw]',
545
- // Layout classes based on scrolling mode
546
- enableScrolling ? 'flex flex-col' : 'grid gap-4 p-6',
547
- // Full screen handling
548
- size === 'full' && 'sm:left-[50%] sm:top-[50%] sm:translate-x-[-50%] sm:translate-y-[-50%] left-0 top-0 translate-x-0 translate-y-0 h-full rounded-none sm:h-auto sm:rounded-lg',
549
- // Overflow handling for scrolling mode
550
- enableScrolling && 'overflow-hidden',
1392
+ 'absolute right-4 top-4 z-10 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none',
551
1393
  className
552
1394
  )}
553
- style={mergedStyle}
554
1395
  {...props}
555
1396
  >
556
- {children}
557
- {showCloseButton && (
558
- <DialogPrimitive.Close className="absolute right-4 top-4 z-10 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
559
- <X className="size-4" />
560
- <span className="sr-only">Close</span>
561
- </DialogPrimitive.Close>
562
- )}
563
- </DialogPrimitive.Content>
564
- </DialogPortal>
565
- );
566
- });
567
- DialogContent.displayName = DialogPrimitive.Content.displayName;
1397
+ <X className="size-4" />
1398
+ <span className="sr-only">Close</span>
1399
+ </button>
1400
+ );
1401
+ }
1402
+ );
1403
+ DialogClose.displayName = 'DialogClose';
568
1404
 
569
1405
  /**
570
1406
  * DialogHeader component
571
1407
  * Semantic header container for dialog title and description with optional sticky behavior
572
- *
573
- * @param props - Header configuration and styling
574
- * @returns JSX.Element - The dialog header container using semantic <header> element
575
- *
576
- * @example
577
- * ```tsx
578
- * <DialogHeader sticky={true}>
579
- * <DialogTitle>Sticky Header</DialogTitle>
580
- * <DialogDescription>This header stays visible while scrolling.</DialogDescription>
581
- * </DialogHeader>
582
- * ```
583
1408
  */
584
1409
  const DialogHeader = ({
585
1410
  className,
@@ -589,7 +1414,7 @@ const DialogHeader = ({
589
1414
  <header
590
1415
  className={cn(
591
1416
  'flex flex-col space-y-1.5 text-center sm:text-left',
592
- sticky ? 'sticky top-0 z-10 bg-background p-6 pb-4 border-b' : 'p-6 pb-4',
1417
+ sticky ? 'sticky top-0 z-10 bg-background pt-6 pb-4 border-b' : 'py-2',
593
1418
  className
594
1419
  )}
595
1420
  {...props}
@@ -600,27 +1425,6 @@ DialogHeader.displayName = 'DialogHeader';
600
1425
  /**
601
1426
  * DialogBody component
602
1427
  * Semantic main content area for dialog body content with scrollable functionality
603
- * Supports both React children and safe HTML content rendering
604
- *
605
- * @param props - Body configuration and styling
606
- * @returns JSX.Element - The scrollable dialog body container using semantic <main> element
607
- *
608
- * @example
609
- * ```tsx
610
- * // Using React children
611
- * <DialogBody>
612
- * <section className="space-y-4">
613
- * <h4>Content Title</h4>
614
- * <p>Long content that will scroll...</p>
615
- * </section>
616
- * </DialogBody>
617
- *
618
- * // Using HTML content
619
- * <DialogBody
620
- * htmlContent="<h2>Import Instructions</h2><p>Upload a CSV file with the following format:</p><ul><li>Required columns: name, email</li><li>Optional columns: phone, address</li></ul>"
621
- * allowHtml={true}
622
- * />
623
- * ```
624
1428
  */
625
1429
  const DialogBody = ({
626
1430
  className,
@@ -640,7 +1444,6 @@ const DialogBody = ({
640
1444
  };
641
1445
  }, [maxHeight, style]);
642
1446
 
643
- // Process HTML content if provided
644
1447
  const processedHtmlContent = React.useMemo(() => {
645
1448
  if (!htmlContent || !allowHtml) {
646
1449
  return null;
@@ -654,13 +1457,46 @@ const DialogBody = ({
654
1457
  return result.html;
655
1458
  }, [htmlContent, allowHtml, strictSanitization, logWarnings]);
656
1459
 
657
- // Determine if htmlContent was provided (even if processing failed)
658
1460
  const hasHtmlContent = Boolean(htmlContent && allowHtml);
659
1461
 
1462
+ // Check if parent dialog has height constraint by checking if it uses flex layout
1463
+ // When dialog has height constraint, it uses flex layout, and DialogBody should
1464
+ // use flex properties to properly participate in the layout and only scroll when constrained
1465
+ const { dialogRef, open } = useDialogContext();
1466
+ const [isInFlexContainer, setIsInFlexContainer] = React.useState(false);
1467
+
1468
+ React.useEffect(() => {
1469
+ if (!open) {
1470
+ setIsInFlexContainer(false);
1471
+ return;
1472
+ }
1473
+
1474
+ // Check if dialog uses flex layout (indicates height constraint)
1475
+ const checkFlexLayout = () => {
1476
+ const dialog = dialogRef?.current;
1477
+ if (!dialog) {
1478
+ setIsInFlexContainer(false);
1479
+ return;
1480
+ }
1481
+ const styles = window.getComputedStyle(dialog);
1482
+ const isFlex = styles.display === 'flex' && styles.flexDirection === 'column';
1483
+ setIsInFlexContainer(isFlex);
1484
+ };
1485
+
1486
+ // Check after a brief delay to ensure styles are applied
1487
+ const timeoutId = setTimeout(checkFlexLayout, 0);
1488
+ checkFlexLayout(); // Also check immediately
1489
+
1490
+ return () => clearTimeout(timeoutId);
1491
+ }, [open, dialogRef]);
1492
+
660
1493
  return (
661
1494
  <main
662
1495
  className={cn(
663
- 'overflow-y-auto px-6 py-2',
1496
+ 'overflow-y-auto py-2',
1497
+ // When in a flex container with height constraint, use flex properties
1498
+ // so DialogBody takes up available space and only scrolls when content exceeds it
1499
+ isInFlexContainer && 'flex-1 min-h-0',
664
1500
  className
665
1501
  )}
666
1502
  style={mergedStyle}
@@ -669,16 +1505,16 @@ const DialogBody = ({
669
1505
  {...props}
670
1506
  >
671
1507
  {processedHtmlContent ? (
672
- <div
1508
+ <p
673
1509
  dangerouslySetInnerHTML={{ __html: processedHtmlContent }}
674
1510
  className="prose prose-sm max-w-none"
675
1511
  />
676
1512
  ) : (
677
1513
  <>
678
1514
  {hasHtmlContent && !processedHtmlContent && (
679
- <div className="text-acc-500 mb-2">
1515
+ <p className="text-acc-500 mb-2">
680
1516
  No HTML content processed. Showing children instead.
681
- </div>
1517
+ </p>
682
1518
  )}
683
1519
  {children}
684
1520
  </>
@@ -691,17 +1527,6 @@ DialogBody.displayName = 'DialogBody';
691
1527
  /**
692
1528
  * DialogFooter component
693
1529
  * Semantic footer container for dialog action buttons with optional sticky behavior
694
- *
695
- * @param props - Footer configuration and styling
696
- * @returns JSX.Element - The dialog footer container using semantic <footer> element
697
- *
698
- * @example
699
- * ```tsx
700
- * <DialogFooter sticky={true}>
701
- * <Button variant="outline">Cancel</Button>
702
- * <Button>Save changes</Button>
703
- * </DialogFooter>
704
- * ```
705
1530
  */
706
1531
  const DialogFooter = ({
707
1532
  className,
@@ -710,9 +1535,8 @@ const DialogFooter = ({
710
1535
  }: DialogFooterProps) => (
711
1536
  <footer
712
1537
  className={cn(
713
- // Only apply default layout classes if no custom className is provided
714
1538
  !className && 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
715
- !className && (sticky ? 'sticky bottom-0 z-10 bg-background p-6 pt-4 border-t' : 'p-6 pt-4'),
1539
+ !className && (sticky ? 'sticky bottom-0 z-10 bg-background pt-4 pb-6 border-t' : 'py-2'),
716
1540
  className
717
1541
  )}
718
1542
  {...props}
@@ -720,62 +1544,70 @@ const DialogFooter = ({
720
1544
  );
721
1545
  DialogFooter.displayName = 'DialogFooter';
722
1546
 
723
- const DialogTitle = React.forwardRef<
724
- React.ElementRef<typeof DialogPrimitive.Title>,
725
- DialogTitleProps
726
- >(({ className, htmlContent, allowHtml = true, children, ...props }, ref) => {
727
- const processedHtmlContent = React.useMemo(() => {
728
- if (!htmlContent || !allowHtml) {
729
- return null;
730
- }
1547
+ /**
1548
+ * DialogTitle component
1549
+ * Title element with ARIA support
1550
+ */
1551
+ const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps>(
1552
+ ({ className, htmlContent, allowHtml = true, children, ...props }, ref) => {
1553
+ const { titleId } = useDialogContext();
731
1554
 
732
- const result = renderSafeHtml(htmlContent, {
733
- strict: true,
734
- logWarnings: false
735
- });
1555
+ const processedHtmlContent = React.useMemo(() => {
1556
+ if (!htmlContent || !allowHtml) {
1557
+ return null;
1558
+ }
736
1559
 
737
- return result.html;
738
- }, [htmlContent, allowHtml]);
1560
+ const result = renderSafeHtml(htmlContent, {
1561
+ strict: true,
1562
+ logWarnings: false
1563
+ });
739
1564
 
740
- return (
741
- <DialogPrimitive.Title
742
- ref={ref}
743
- className={cn(
744
- className
745
- )}
746
- {...props}
747
- >
748
- {processedHtmlContent ? (
749
- <span dangerouslySetInnerHTML={{ __html: processedHtmlContent }} />
750
- ) : (
751
- children
752
- )}
753
- </DialogPrimitive.Title>
754
- );
755
- });
756
- DialogTitle.displayName = DialogPrimitive.Title.displayName;
1565
+ return result.html;
1566
+ }, [htmlContent, allowHtml]);
757
1567
 
758
- const DialogDescription = React.forwardRef<
759
- HTMLHeadingElement,
760
- DialogDescriptionProps
761
- >(({ className, htmlContent, allowHtml = true, children, ...props }, ref) => {
762
- const processedHtmlContent = React.useMemo(() => {
763
- if (!htmlContent || !allowHtml) {
764
- return null;
765
- }
1568
+ return (
1569
+ <h2
1570
+ ref={ref}
1571
+ id={titleId}
1572
+ className={cn(className)}
1573
+ {...props}
1574
+ >
1575
+ {processedHtmlContent ? (
1576
+ <span dangerouslySetInnerHTML={{ __html: processedHtmlContent }} />
1577
+ ) : (
1578
+ children
1579
+ )}
1580
+ </h2>
1581
+ );
1582
+ }
1583
+ );
1584
+ DialogTitle.displayName = 'DialogTitle';
766
1585
 
767
- const result = renderSafeHtml(htmlContent, {
768
- strict: true,
769
- logWarnings: false
770
- });
1586
+ /**
1587
+ * DialogDescription component
1588
+ * Description element with ARIA support
1589
+ */
1590
+ const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
1591
+ ({ className, htmlContent, allowHtml = true, children, ...props }, ref) => {
1592
+ const { descriptionId } = useDialogContext();
771
1593
 
772
- return result.html;
773
- }, [htmlContent, allowHtml]);
1594
+ const processedHtmlContent = React.useMemo(() => {
1595
+ if (!htmlContent || !allowHtml) {
1596
+ return null;
1597
+ }
774
1598
 
775
- return (
776
- <DialogPrimitive.Description asChild>
777
- <h5
1599
+ const result = renderSafeHtml(htmlContent, {
1600
+ strict: true,
1601
+ logWarnings: false
1602
+ });
1603
+
1604
+ return result.html;
1605
+ }, [htmlContent, allowHtml]);
1606
+
1607
+ return (
1608
+ <p
778
1609
  ref={ref}
1610
+ id={descriptionId}
779
1611
  className={cn(className)}
780
1612
  {...props}
781
1613
  >
@@ -784,16 +1616,15 @@ const DialogDescription = React.forwardRef<
784
1616
  ) : (
785
1617
  children
786
1618
  )}
787
- </h5>
788
- </DialogPrimitive.Description>
789
- );
790
- });
791
- DialogDescription.displayName = DialogPrimitive.Description.displayName;
1619
+ </p>
1620
+ );
1621
+ }
1622
+ );
1623
+ DialogDescription.displayName = 'DialogDescription';
792
1624
 
793
1625
  export {
794
1626
  Dialog,
795
1627
  DialogPortal,
796
- DialogOverlay,
797
1628
  DialogClose,
798
1629
  DialogTrigger,
799
1630
  DialogContent,