@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.
- package/CHANGELOG.md +104 -0
- package/README.md +5 -403
- package/core-usage-manifest.json +93 -0
- package/cursor-rules/00-pace-core-compliance.mdc +128 -26
- package/cursor-rules/01-standards-compliance.mdc +49 -8
- package/cursor-rules/02-project-structure.mdc +6 -0
- package/cursor-rules/03-solid-principles.mdc +2 -0
- package/cursor-rules/04-testing-standards.mdc +2 -0
- package/cursor-rules/05-bug-reports-and-features.mdc +2 -0
- package/cursor-rules/06-code-quality.mdc +2 -0
- package/cursor-rules/07-tech-stack-compliance.mdc +2 -0
- package/cursor-rules/08-markup-quality.mdc +52 -27
- package/cursor-rules/09-rbac-compliance.mdc +462 -0
- package/cursor-rules/10-error-handling-patterns.mdc +179 -0
- package/cursor-rules/11-performance-optimization.mdc +169 -0
- package/cursor-rules/12-ci-cd-integration.mdc +150 -0
- package/dist/{AuthService-Cb34EQs3.d.ts → AuthService-DmfO5rGS.d.ts} +10 -0
- package/dist/{DataTable-BMRU8a1j.d.ts → DataTable-2N_tqbfq.d.ts} +1 -1
- package/dist/DataTable-LRJL4IRV.js +15 -0
- package/dist/{PublicPageProvider-DEMpysFR.d.ts → PublicPageProvider-BBH6Vqg7.d.ts} +72 -139
- package/dist/UnifiedAuthProvider-ZT6TIGM7.js +7 -0
- package/dist/api-Y4MQWOFW.js +4 -0
- package/dist/audit-MYQXYZFU.js +3 -0
- package/dist/{chunk-J36DSWQK.js → chunk-2HGJFNAH.js} +8 -28
- package/dist/{chunk-OEWDTMG7.js → chunk-3O3WHILE.js} +38 -121
- package/dist/{chunk-M43Y4SSO.js → chunk-3QC3KRHK.js} +1 -14
- package/dist/{chunk-DGUM43GV.js → chunk-3RG5ZIWI.js} +1 -4
- package/dist/{chunk-QXHPKYJV.js → chunk-4SXLQIZO.js} +1 -26
- package/dist/chunk-4T7OBVTU.js +62 -0
- package/dist/{chunk-E66EQZE6.js → chunk-6GLLNA6U.js} +3 -9
- package/dist/{chunk-ZSAAAMVR.js → chunk-6QYDGKQY.js} +1 -4
- package/dist/{chunk-NN6WWZ5U.js → chunk-7TYHROIV.js} +579 -563
- package/dist/{chunk-M7MPQISP.js → chunk-A55DK444.js} +9 -16
- package/dist/{chunk-63FOKYGO.js → chunk-AHU7G2R5.js} +2 -11
- package/dist/{chunk-L4OXEN46.js → chunk-BVP2BCJF.js} +2 -16
- package/dist/chunk-C7NSAPTL.js +1 -0
- package/dist/{chunk-YKRAFF5K.js → chunk-FENMYN2U.js} +73 -149
- package/dist/{chunk-AVMLPIM7.js → chunk-FTCRZOG2.js} +284 -432
- package/dist/{chunk-G37KK66H.js → chunk-FYHN4DD5.js} +60 -19
- package/dist/{chunk-VBXEHIUJ.js → chunk-HF6O3O37.js} +6 -88
- package/dist/{chunk-I6DAQMWX.js → chunk-LAZMKTTF.js} +930 -891
- package/dist/{chunk-5EC5MEWX.js → chunk-MAGBIDNS.js} +77 -222
- package/dist/chunk-MBADTM7L.js +64 -0
- package/dist/chunk-OHIK3MIO.js +994 -0
- package/dist/{chunk-6SOIHG6Z.js → chunk-S7DKJPLT.js} +115 -44
- package/dist/{chunk-FMUCXFII.js → chunk-SD6WQY43.js} +1 -5
- package/dist/{chunk-PWLANIRT.js → chunk-TTRFSOKR.js} +1 -7
- package/dist/{chunk-5DRSZLL2.js → chunk-UH3NTO3F.js} +1 -6
- package/dist/{chunk-FFQEQTNW.js → chunk-UIYSCEV7.js} +134 -45
- package/dist/{chunk-3LPHPB62.js → chunk-ZFYPMX46.js} +271 -87
- package/dist/{chunk-7JPAB3T5.js → chunk-ZS5VO5JB.js} +1989 -1283
- package/dist/components.d.ts +6 -6
- package/dist/components.js +57 -267
- package/dist/{database.generated-CzIvgcPu.d.ts → database.generated-CcnC_DRc.d.ts} +4795 -3691
- package/dist/eslint-rules/index.cjs +22 -0
- package/dist/eslint-rules/rules/compliance.cjs +348 -0
- package/dist/eslint-rules/rules/components.cjs +113 -0
- package/dist/eslint-rules/rules/imports.cjs +102 -0
- package/dist/eslint-rules/rules/rbac.cjs +790 -0
- package/dist/eslint-rules/utils/helpers.cjs +42 -0
- package/dist/eslint-rules/utils/manifest-loader.cjs +75 -0
- package/dist/hooks.d.ts +5 -5
- package/dist/hooks.js +62 -270
- package/dist/icons/index.d.ts +1 -0
- package/dist/icons/index.js +1 -0
- package/dist/index.d.ts +36 -26
- package/dist/index.js +87 -690
- package/dist/providers.d.ts +2 -2
- package/dist/providers.js +8 -35
- package/dist/rbac/eslint-rules.d.ts +46 -44
- package/dist/rbac/eslint-rules.js +7 -4
- package/dist/rbac/index.d.ts +124 -594
- package/dist/rbac/index.js +14 -207
- package/dist/styles/index.js +2 -12
- package/dist/theming/runtime.js +3 -19
- package/dist/{timezone-CHhWg6b4.d.ts → timezone-BZe_eUxx.d.ts} +175 -1
- package/dist/{types-CkbwOr4Y.d.ts → types-B-K_5VnO.d.ts} +4 -0
- package/dist/types-t9H8qKRw.d.ts +55 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.js +7 -94
- package/dist/{usePublicRouteParams-i3qtoBgg.d.ts → usePublicRouteParams-COZ28Mvq.d.ts} +9 -9
- package/dist/utils.d.ts +24 -117
- package/dist/utils.js +54 -392
- package/docs/README.md +16 -6
- package/docs/api/README.md +4 -402
- package/docs/api/modules.md +454 -930
- package/docs/api-reference/components.md +3 -1
- package/docs/api-reference/deprecated.md +31 -6
- package/docs/api-reference/rpc-functions.md +78 -3
- package/docs/best-practices/accessibility.md +6 -3
- package/docs/getting-started/cursor-rules.md +3 -23
- package/docs/getting-started/dependencies.md +650 -0
- package/docs/getting-started/installation-guide.md +20 -7
- package/docs/getting-started/quick-start.md +23 -12
- package/docs/implementation-guides/permission-enforcement.md +4 -0
- package/docs/rbac/MIGRATION_GUIDE.md +819 -0
- package/docs/rbac/RBAC_CONTRACT.md +724 -0
- package/docs/rbac/README.md +12 -3
- package/docs/rbac/edge-functions-guide.md +376 -0
- package/docs/rbac/secure-client-protection.md +0 -34
- package/docs/standards/00-pace-core-compliance.md +967 -0
- package/docs/standards/01-standards-compliance.md +188 -0
- package/docs/standards/02-project-structure.md +985 -0
- package/docs/standards/03-solid-principles.md +39 -0
- package/docs/standards/04-testing-standards.md +36 -0
- package/docs/standards/05-bug-reports-and-features.md +27 -0
- package/docs/standards/{04-code-style-standard.md → 06-code-quality.md} +2 -0
- package/docs/standards/07-tech-stack-compliance.md +30 -0
- package/docs/standards/08-markup-quality.md +345 -0
- package/docs/standards/{07-rbac-and-rls-standard.md → 09-rbac-compliance.md} +149 -54
- package/docs/standards/10-error-handling-patterns.md +401 -0
- package/docs/standards/11-performance-optimization.md +348 -0
- package/docs/standards/12-ci-cd-integration.md +370 -0
- package/docs/standards/ALIGNMENT_REVIEW_SUMMARY.md +192 -0
- package/docs/standards/README.md +62 -33
- package/docs/troubleshooting/organisation-context-setup.md +42 -19
- package/eslint-config-pace-core.cjs +20 -4
- package/package.json +31 -21
- package/scripts/audit/audit-compliance.cjs +1295 -0
- package/scripts/audit/audit-components.cjs +260 -0
- package/scripts/audit/audit-dependencies.cjs +395 -0
- package/scripts/audit/audit-rbac.cjs +954 -0
- package/scripts/audit/audit-standards.cjs +1268 -0
- package/scripts/audit/index.cjs +1898 -194
- package/scripts/install-cursor-rules.cjs +259 -8
- package/scripts/validate-master.js +1 -1
- package/src/__tests__/fixtures/supabase.ts +1 -1
- package/src/__tests__/helpers/__tests__/component-test-utils.test.tsx +1 -1
- package/src/__tests__/helpers/__tests__/optimized-test-setup.test.ts +1 -1
- package/src/__tests__/helpers/__tests__/supabaseMock.test.ts +1 -1
- package/src/__tests__/helpers/__tests__/test-utils.test.tsx +3 -3
- package/src/__tests__/helpers/component-test-utils.tsx +1 -1
- package/src/__tests__/helpers/supabaseMock.ts +2 -2
- package/src/__tests__/public-recipe-view.test.ts +38 -9
- package/src/components/Button/Button.tsx +5 -1
- package/src/components/ContextSelector/ContextSelector.tsx +42 -39
- package/src/components/DataTable/__tests__/keyboard.test.tsx +15 -2
- package/src/components/DataTable/components/DataTableBody.tsx +55 -31
- package/src/components/DataTable/components/DataTableCore.tsx +186 -13
- package/src/components/DataTable/components/DataTableLayout.tsx +30 -5
- package/src/components/DataTable/components/EditFields.tsx +23 -3
- package/src/components/DataTable/components/EditableRow.tsx +7 -2
- package/src/components/DataTable/components/ImportModal.tsx +4 -6
- package/src/components/DataTable/components/RowComponent.tsx +12 -0
- package/src/components/DataTable/components/ViewRowModal.tsx +4 -4
- package/src/components/DataTable/components/__tests__/ImportModal.test.tsx +455 -96
- package/src/components/DataTable/components/__tests__/ViewRowModal.test.tsx +122 -58
- package/src/components/DataTable/components/hooks/usePermissionTracking.ts +0 -4
- package/src/components/DataTable/core/DataTableContext.tsx +1 -1
- package/src/components/DataTable/hooks/__tests__/useDataTableState.test.ts +51 -47
- package/src/components/DataTable/hooks/useDataTablePermissions.ts +24 -21
- package/src/components/DataTable/hooks/useDataTableState.ts +125 -9
- package/src/components/DataTable/hooks/useTableColumns.ts +40 -2
- package/src/components/DataTable/hooks/useTableHandlers.ts +11 -0
- package/src/components/DataTable/types.ts +5 -0
- package/src/components/DateTimeField/DateTimeField.tsx +20 -20
- package/src/components/DateTimeField/README.md +5 -2
- package/src/components/Dialog/Dialog.test.tsx +361 -318
- package/src/components/Dialog/Dialog.tsx +1154 -323
- package/src/components/Dialog/index.ts +3 -3
- package/src/components/FileDisplay/FileDisplay.test.tsx +45 -2
- package/src/components/FileDisplay/FileDisplay.tsx +28 -22
- package/src/components/Form/Form.test.tsx +9 -10
- package/src/components/Form/Form.tsx +369 -9
- package/src/components/InactivityWarningModal/InactivityWarningModal.test.tsx +28 -28
- package/src/components/InactivityWarningModal/InactivityWarningModal.tsx +40 -54
- package/src/components/LoginForm/LoginForm.tsx +2 -2
- package/src/components/NavigationMenu/NavigationMenu.test.tsx +14 -13
- package/src/components/NavigationMenu/NavigationMenu.tsx +2 -2
- package/src/components/NavigationMenu/useNavigationFiltering.ts +11 -21
- package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +6 -4
- package/src/components/PaceAppLayout/PaceAppLayout.tsx +30 -41
- package/src/components/PaceAppLayout/README.md +10 -9
- package/src/components/PaceAppLayout/test-setup.tsx +40 -31
- package/src/components/PaceLoginPage/PaceLoginPage.test.tsx +108 -61
- package/src/components/PaceLoginPage/PaceLoginPage.tsx +27 -3
- package/src/components/PasswordChange/PasswordChangeForm.test.tsx +61 -0
- package/src/components/PasswordChange/PasswordChangeForm.tsx +20 -13
- package/src/components/PublicLayout/PublicLayout.test.tsx +7 -3
- package/src/components/PublicLayout/PublicPageLayout.tsx +5 -8
- package/src/components/Select/Select.tsx +23 -21
- package/src/components/Select/types.ts +1 -1
- package/src/components/UserMenu/UserMenu.test.tsx +38 -6
- package/src/components/UserMenu/UserMenu.tsx +39 -34
- package/src/components/index.ts +3 -4
- package/src/eslint-rules/index.cjs +22 -0
- package/src/eslint-rules/rules/compliance.cjs +348 -0
- package/src/eslint-rules/rules/components.cjs +113 -0
- package/src/eslint-rules/rules/imports.cjs +102 -0
- package/src/eslint-rules/rules/rbac.cjs +790 -0
- package/src/eslint-rules/utils/helpers.cjs +42 -0
- package/src/eslint-rules/utils/manifest-loader.cjs +75 -0
- package/src/hooks/__tests__/hooks.integration.test.tsx +6 -8
- package/src/hooks/__tests__/useAppConfig.unit.test.ts +129 -67
- package/src/hooks/__tests__/usePublicEvent.simple.test.ts +149 -67
- package/src/hooks/__tests__/usePublicEvent.test.ts +149 -79
- package/src/hooks/__tests__/usePublicEvent.unit.test.ts +158 -109
- package/src/hooks/__tests__/useSessionDraft.test.ts +163 -0
- package/src/hooks/__tests__/useSessionRestoration.unit.test.tsx +10 -5
- package/src/hooks/public/usePublicEvent.ts +62 -190
- package/src/hooks/public/usePublicEventLogo.test.ts +70 -17
- package/src/hooks/public/usePublicEventLogo.ts +19 -9
- package/src/hooks/useAppConfig.ts +26 -24
- package/src/hooks/useEventTheme.test.ts +211 -233
- package/src/hooks/useEventTheme.ts +19 -28
- package/src/hooks/useEvents.ts +11 -7
- package/src/hooks/useKeyboardShortcuts.ts +1 -1
- package/src/hooks/useOrganisationPermissions.ts +9 -11
- package/src/hooks/useOrganisations.ts +13 -7
- package/src/hooks/useQueryCache.ts +0 -1
- package/src/hooks/useSessionDraft.ts +380 -0
- package/src/hooks/useSessionRestoration.ts +3 -1
- package/src/icons/index.ts +27 -0
- package/src/index.ts +16 -1
- package/src/providers/OrganisationProvider.tsx +23 -14
- package/src/providers/services/EventServiceProvider.tsx +1 -24
- package/src/providers/services/UnifiedAuthProvider.tsx +5 -48
- package/src/providers/services/__tests__/UnifiedAuthProvider.integration.test.tsx +3 -0
- package/src/rbac/README.md +20 -20
- package/src/rbac/__tests__/adapters.comprehensive.test.tsx +7 -457
- package/src/rbac/__tests__/auth-rbac.e2e.test.tsx +33 -7
- package/src/rbac/adapters.tsx +7 -295
- package/src/rbac/api.test.ts +44 -56
- package/src/rbac/api.ts +10 -17
- package/src/rbac/cache-invalidation.ts +0 -1
- package/src/rbac/compliance/index.ts +10 -0
- package/src/rbac/compliance/pattern-detector.ts +553 -0
- package/src/rbac/compliance/runtime-compliance.ts +22 -0
- package/src/rbac/components/AccessDenied.tsx +150 -0
- package/src/rbac/components/NavigationGuard.tsx +12 -20
- package/src/rbac/components/PagePermissionGuard.tsx +4 -24
- package/src/rbac/components/__tests__/NavigationGuard.test.tsx +21 -8
- package/src/rbac/components/index.ts +3 -41
- package/src/rbac/eslint-rules.js +1 -1
- package/src/rbac/hooks/index.ts +0 -3
- package/src/rbac/hooks/permissions/index.ts +0 -3
- package/src/rbac/hooks/permissions/useAccessLevel.ts +4 -8
- package/src/rbac/hooks/usePermissions.ts +0 -3
- package/src/rbac/hooks/useRBAC.test.ts +21 -3
- package/src/rbac/hooks/useRBAC.ts +4 -3
- package/src/rbac/hooks/useResolvedScope.test.ts +57 -47
- package/src/rbac/hooks/useResolvedScope.ts +58 -140
- package/src/rbac/hooks/useResourcePermissions.test.ts +241 -60
- package/src/rbac/hooks/useResourcePermissions.ts +182 -63
- package/src/rbac/hooks/useRoleManagement.test.ts +65 -22
- package/src/rbac/hooks/useRoleManagement.ts +147 -19
- package/src/rbac/hooks/useSecureSupabase.ts +4 -8
- package/src/rbac/index.ts +7 -9
- package/src/rbac/permissions.ts +17 -17
- package/src/rbac/utils/contextValidator.ts +45 -7
- package/src/services/AuthService.ts +132 -23
- package/src/services/EventService.ts +4 -97
- package/src/services/InactivityService.ts +155 -58
- package/src/services/OrganisationService.ts +7 -44
- package/src/services/__tests__/OrganisationService.test.ts +26 -8
- package/src/services/base/BaseService.ts +0 -3
- package/src/styles/core.css +4 -0
- package/src/types/database.generated.ts +4733 -3809
- package/src/utils/__tests__/organisationContext.unit.test.ts +9 -10
- package/src/utils/context/organisationContext.test.ts +13 -28
- package/src/utils/context/organisationContext.ts +21 -52
- package/src/utils/dynamic/dynamicUtils.ts +1 -1
- package/src/utils/file-reference/index.ts +39 -15
- package/src/utils/formatting/formatDateTime.test.ts +3 -2
- package/src/utils/formatting/formatTime.test.ts +3 -2
- package/src/utils/google-places/loadGoogleMapsScript.ts +29 -4
- package/src/utils/index.ts +4 -1
- package/src/utils/persistence/__tests__/keyDerivation.test.ts +135 -0
- package/src/utils/persistence/__tests__/sensitiveFieldDetection.test.ts +123 -0
- package/src/utils/persistence/keyDerivation.ts +304 -0
- package/src/utils/persistence/sensitiveFieldDetection.ts +212 -0
- package/src/utils/security/secureStorage.ts +5 -5
- package/src/utils/storage/helpers.ts +3 -3
- package/src/utils/supabase/createBaseClient.ts +147 -0
- package/src/utils/timezone/timezone.test.ts +1 -2
- package/src/utils/timezone/timezone.ts +1 -1
- package/src/utils/validation/csrf.ts +4 -4
- package/cursor-rules/CHANGELOG.md +0 -119
- package/cursor-rules/README.md +0 -192
- package/dist/DataTable-E7YQZD7D.js +0 -175
- package/dist/DataTable-E7YQZD7D.js.map +0 -1
- package/dist/UnifiedAuthProvider-QPXO24B4.js +0 -18
- package/dist/UnifiedAuthProvider-QPXO24B4.js.map +0 -1
- package/dist/api-6LVZTHDS.js +0 -52
- package/dist/api-6LVZTHDS.js.map +0 -1
- package/dist/audit-V53FV5AG.js +0 -17
- package/dist/audit-V53FV5AG.js.map +0 -1
- package/dist/chunk-36LVWXB2.js +0 -227
- package/dist/chunk-36LVWXB2.js.map +0 -1
- package/dist/chunk-3LPHPB62.js.map +0 -1
- package/dist/chunk-5DRSZLL2.js.map +0 -1
- package/dist/chunk-5EC5MEWX.js.map +0 -1
- package/dist/chunk-63FOKYGO.js.map +0 -1
- package/dist/chunk-6SOIHG6Z.js.map +0 -1
- package/dist/chunk-7JPAB3T5.js.map +0 -1
- package/dist/chunk-ATKZM7RX.js +0 -2053
- package/dist/chunk-ATKZM7RX.js.map +0 -1
- package/dist/chunk-AVMLPIM7.js.map +0 -1
- package/dist/chunk-DGUM43GV.js.map +0 -1
- package/dist/chunk-E66EQZE6.js.map +0 -1
- package/dist/chunk-FFQEQTNW.js.map +0 -1
- package/dist/chunk-FMUCXFII.js.map +0 -1
- package/dist/chunk-G37KK66H.js.map +0 -1
- package/dist/chunk-I6DAQMWX.js.map +0 -1
- package/dist/chunk-J36DSWQK.js.map +0 -1
- package/dist/chunk-KQCRWDSA.js +0 -1
- package/dist/chunk-KQCRWDSA.js.map +0 -1
- package/dist/chunk-L4OXEN46.js.map +0 -1
- package/dist/chunk-LMC26NLJ.js +0 -84
- package/dist/chunk-LMC26NLJ.js.map +0 -1
- package/dist/chunk-M43Y4SSO.js.map +0 -1
- package/dist/chunk-M7MPQISP.js.map +0 -1
- package/dist/chunk-NN6WWZ5U.js.map +0 -1
- package/dist/chunk-OEWDTMG7.js.map +0 -1
- package/dist/chunk-PWLANIRT.js.map +0 -1
- package/dist/chunk-QXHPKYJV.js.map +0 -1
- package/dist/chunk-VBXEHIUJ.js.map +0 -1
- package/dist/chunk-YKRAFF5K.js.map +0 -1
- package/dist/chunk-ZSAAAMVR.js.map +0 -1
- package/dist/components.js.map +0 -1
- package/dist/contextValidator-OOPCLPZW.js +0 -9
- package/dist/contextValidator-OOPCLPZW.js.map +0 -1
- package/dist/eslint-rules/pace-core-compliance.cjs +0 -510
- package/dist/hooks.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/providers.js.map +0 -1
- package/dist/rbac/eslint-rules.js.map +0 -1
- package/dist/rbac/index.js.map +0 -1
- package/dist/styles/index.js.map +0 -1
- package/dist/theming/runtime.js.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/utils.js.map +0 -1
- package/docs/standards/01-architecture-standard.md +0 -44
- package/docs/standards/02-api-and-rpc-standard.md +0 -39
- package/docs/standards/03-component-standard.md +0 -32
- package/docs/standards/05-security-standard.md +0 -44
- package/docs/standards/06-testing-and-docs-standard.md +0 -29
- package/docs/standards/pace-core-compliance.md +0 -432
- package/scripts/audit/core/checks/accessibility.cjs +0 -197
- package/scripts/audit/core/checks/api-usage.cjs +0 -191
- package/scripts/audit/core/checks/bundle.cjs +0 -142
- package/scripts/audit/core/checks/compliance.cjs +0 -2706
- package/scripts/audit/core/checks/config.cjs +0 -54
- package/scripts/audit/core/checks/coverage.cjs +0 -84
- package/scripts/audit/core/checks/dependencies.cjs +0 -994
- package/scripts/audit/core/checks/documentation.cjs +0 -268
- package/scripts/audit/core/checks/environment.cjs +0 -116
- package/scripts/audit/core/checks/error-handling.cjs +0 -340
- package/scripts/audit/core/checks/forms.cjs +0 -172
- package/scripts/audit/core/checks/heuristics.cjs +0 -68
- package/scripts/audit/core/checks/hooks.cjs +0 -334
- package/scripts/audit/core/checks/imports.cjs +0 -244
- package/scripts/audit/core/checks/performance.cjs +0 -325
- package/scripts/audit/core/checks/routes.cjs +0 -117
- package/scripts/audit/core/checks/state.cjs +0 -130
- package/scripts/audit/core/checks/structure.cjs +0 -65
- package/scripts/audit/core/checks/style.cjs +0 -584
- package/scripts/audit/core/checks/testing.cjs +0 -122
- package/scripts/audit/core/checks/typescript.cjs +0 -61
- package/scripts/audit/core/scanner.cjs +0 -199
- package/scripts/audit/core/utils.cjs +0 -137
- package/scripts/audit/reporters/console.cjs +0 -151
- package/scripts/audit/reporters/json.cjs +0 -54
- package/scripts/audit/reporters/markdown.cjs +0 -124
- package/scripts/audit-consuming-app.cjs +0 -86
- package/src/eslint-rules/pace-core-compliance.cjs +0 -510
- package/src/eslint-rules/pace-core-compliance.js +0 -638
- package/src/rbac/components/EnhancedNavigationMenu.test.tsx +0 -555
- package/src/rbac/components/EnhancedNavigationMenu.tsx +0 -293
- package/src/rbac/components/NavigationProvider.test.tsx +0 -481
- package/src/rbac/components/NavigationProvider.tsx +0 -345
- package/src/rbac/components/PagePermissionProvider.test.tsx +0 -476
- package/src/rbac/components/PagePermissionProvider.tsx +0 -279
- package/src/rbac/components/PermissionEnforcer.tsx +0 -312
- package/src/rbac/components/RoleBasedRouter.tsx +0 -440
- package/src/rbac/components/SecureDataProvider.test.tsx +0 -543
- package/src/rbac/components/SecureDataProvider.tsx +0 -339
- package/src/rbac/components/__tests__/EnhancedNavigationMenu.test.tsx +0 -620
- package/src/rbac/components/__tests__/NavigationProvider.test.tsx +0 -726
- package/src/rbac/components/__tests__/PagePermissionProvider.test.tsx +0 -661
- package/src/rbac/components/__tests__/PermissionEnforcer.test.tsx +0 -881
- package/src/rbac/components/__tests__/RoleBasedRouter.test.tsx +0 -783
- package/src/rbac/components/__tests__/SecureDataProvider.fixed.test.tsx +0 -645
- package/src/rbac/components/__tests__/SecureDataProvider.test.tsx +0 -659
- package/src/rbac/hooks/permissions/useCachedPermissions.ts +0 -79
- package/src/rbac/hooks/permissions/useHasAllPermissions.ts +0 -90
- 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
|
|
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
|
|
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
|
-
* <
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
187
|
+
* Uses semantic HTML dialog element
|
|
213
188
|
* @public
|
|
214
189
|
*/
|
|
215
|
-
export interface DialogContentProps extends React.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
<
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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);
|
|
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);
|
|
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
|
-
//
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
651
|
+
enableScrolling = false,
|
|
652
|
+
maxHeight,
|
|
490
653
|
maxWidth,
|
|
491
654
|
minHeight,
|
|
492
655
|
minWidth,
|
|
493
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
//
|
|
517
|
-
const
|
|
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
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
526
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
534
|
-
|
|
1389
|
+
type="button"
|
|
1390
|
+
onClick={handleClick}
|
|
535
1391
|
className={cn(
|
|
536
|
-
'
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
1515
|
+
<p className="text-acc-500 mb-2">
|
|
680
1516
|
No HTML content processed. Showing children instead.
|
|
681
|
-
</
|
|
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
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
1555
|
+
const processedHtmlContent = React.useMemo(() => {
|
|
1556
|
+
if (!htmlContent || !allowHtml) {
|
|
1557
|
+
return null;
|
|
1558
|
+
}
|
|
736
1559
|
|
|
737
|
-
|
|
738
|
-
|
|
1560
|
+
const result = renderSafeHtml(htmlContent, {
|
|
1561
|
+
strict: true,
|
|
1562
|
+
logWarnings: false
|
|
1563
|
+
});
|
|
739
1564
|
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
773
|
-
|
|
1594
|
+
const processedHtmlContent = React.useMemo(() => {
|
|
1595
|
+
if (!htmlContent || !allowHtml) {
|
|
1596
|
+
return null;
|
|
1597
|
+
}
|
|
774
1598
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
</
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
DialogDescription.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,
|