diff --git a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx index 9607f48e..f7b030c3 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx @@ -112,8 +112,9 @@ describe('ModalRenderer', () => { }); it('FE-SN-MODAL-005: CTA nav button dismisses all notices (not just current)', async () => { - const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } }); - const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' }); + // CTA is only shown on the last page; navigate there first + const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' }); + const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } }); useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true }); const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss'); @@ -121,6 +122,12 @@ describe('ModalRenderer', () => { await flushGraceDelay(); + // Navigate to last page + await act(async () => { + fireEvent.click(screen.getByLabelText('Go to notice 2')); + }); + await flushGraceDelay(); + const ctaBtn = screen.getByRole('button', { name: 'Go to trips' }); await act(async () => { fireEvent.click(ctaBtn); @@ -299,17 +306,22 @@ describe('ModalRenderer', () => { expect(screen.getByText('Notice A')).toBeTruthy(); expect(screen.getByText('1 / 3')).toBeTruthy(); - // Dismiss notice A — store shrinks, parent re-renders with [B, C] + // Navigate to last page where X button is available await act(async () => { - fireEvent.click(screen.getByLabelText('Dismiss')); - useSystemNoticeStore.setState({ notices: [noticeB, noticeC], loaded: true }); - rerender(); + fireEvent.click(screen.getByLabelText('Go to notice 3')); }); await flushGraceDelay(); - // Must show B (idx=0), not C (idx=1 — the old buggy behavior) - expect(screen.getByText('Notice B')).toBeTruthy(); - expect(screen.getByText('1 / 2')).toBeTruthy(); + // Dismiss all from last page — store shrinks + await act(async () => { + fireEvent.click(screen.getByLabelText('Dismiss')); + useSystemNoticeStore.setState({ notices: [], loaded: true }); + rerender(); + }); + await flushGraceDelay(); + + // All dismissed — modal should be gone + expect(screen.queryByRole('dialog')).toBeNull(); }); it('FE-SN-MODAL-017: X button dismisses all notices, not just the current one', async () => { @@ -321,6 +333,12 @@ describe('ModalRenderer', () => { render(); await flushGraceDelay(); + // X button only appears on the last page — navigate there + await act(async () => { + fireEvent.click(screen.getByLabelText('Go to notice 2')); + }); + await flushGraceDelay(); + await act(async () => { fireEvent.click(screen.getByLabelText('Dismiss')); }); @@ -330,7 +348,7 @@ describe('ModalRenderer', () => { expect(dismissSpy).toHaveBeenCalledTimes(2); }); - it('FE-SN-MODAL-018: ESC key dismisses all notices when current is dismissible', async () => { + it('FE-SN-MODAL-018: ESC key dismisses all notices when on last page', async () => { const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' }); const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' }); useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true }); @@ -339,6 +357,12 @@ describe('ModalRenderer', () => { render(); await flushGraceDelay(); + // ESC only works on last page — navigate there first + await act(async () => { + fireEvent.click(screen.getByLabelText('Go to notice 2')); + }); + await flushGraceDelay(); + await act(async () => { fireEvent.keyDown(document, { key: 'Escape' }); }); diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 295373f6..648fb985 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -1,5 +1,4 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react' -import { createPortal } from 'react-dom' import { useParams, useNavigate } from 'react-router-dom' import { useJourneyStore } from '../store/journeyStore' import { useAuthStore } from '../store/authStore' @@ -228,16 +227,15 @@ export default function JourneyDetailPage() { /> )} - {/* Fullscreen entry view (mobile) — portal to body for iOS stacking */} - {viewingEntry && createPortal( + {/* Fullscreen entry view (mobile) */} + {viewingEntry && ( setViewingEntry(null)} onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }} onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }} onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} - />, - document.body + /> )} {/* Floating tab toggle on mobile combined view */} @@ -580,8 +578,8 @@ export default function JourneyDetailPage() { - {/* Entry Editor — portal to body to escape stacking context on iOS */} - {editingEntry && createPortal( + {/* Entry Editor */} + {editingEntry && ( , - document.body + /> )} {/* Journey Settings */} @@ -2093,7 +2090,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa } return ( -
+