diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx index a47ecd3..5c8edd7 100644 --- a/apps/pwa/src/components/CalendarShell.tsx +++ b/apps/pwa/src/components/CalendarShell.tsx @@ -31,6 +31,7 @@ import { fetchMe, fetchEvents } from '../api/client.js' import { hydrateEvents } from '../lib/hydrateEvents.js' import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js' import { useCalendarStore } from '../store/calendarStore.js' +import { EventDetailPopover } from './EventDetailPopover.js' // ── Helpers ──────────────────────────────────────────────────────────────── @@ -177,8 +178,14 @@ export function CalendarShell() { {/* Schedule-X calendar fills available space */}
- +
+ + {/* EventDetailPopover — standalone mode driven by Zustand openEventId */} + ) } diff --git a/apps/pwa/src/components/EventDetailPopover.test.tsx b/apps/pwa/src/components/EventDetailPopover.test.tsx index 428aa52..762aa0d 100644 --- a/apps/pwa/src/components/EventDetailPopover.test.tsx +++ b/apps/pwa/src/components/EventDetailPopover.test.tsx @@ -31,7 +31,9 @@ vi.mock('../store/calendarStore.js', () => ({ // ── Fixtures ─────────────────────────────────────────────────────────────────── -const TIMED_OCCURRENCE = { +import type { CalendarOccurrence } from '../api/client.js' + +const TIMED_OCCURRENCE: CalendarOccurrence = { id: 'test-uid::2026-06-15T10:00:00', uid: 'test-uid', calendarId: 1, @@ -47,7 +49,7 @@ const TIMED_OCCURRENCE = { description: 'Daily team sync meeting', } -const OCCURRENCE_WITH_HTML = { +const OCCURRENCE_WITH_HTML: CalendarOccurrence = { ...TIMED_OCCURRENCE, id: 'xss-uid::2026-06-15T10:00:00', uid: 'xss-uid', @@ -56,7 +58,7 @@ const OCCURRENCE_WITH_HTML = { location: 'Room', } -const ALLDAY_OCCURRENCE = { +const ALLDAY_OCCURRENCE: CalendarOccurrence = { id: 'allday-uid::2026-06-20', uid: 'allday-uid', calendarId: 2, @@ -81,7 +83,7 @@ import { useCalendarStore } from '../store/calendarStore.js' function renderPopover(occurrence = TIMED_OCCURRENCE) { mockOpenEventId = occurrence.id - ;(useCalendarStore as ReturnType).mockImplementation(() => ({ + ;(useCalendarStore as unknown as ReturnType).mockImplementation(() => ({ openEventId: mockOpenEventId, setOpenEventId: mockSetOpenEventId, })) @@ -157,7 +159,7 @@ describe('EventDetailPopover', () => { it('renders nothing when openEventId is null', () => { mockOpenEventId = null - ;(useCalendarStore as ReturnType).mockImplementation(() => ({ + ;(useCalendarStore as unknown as ReturnType).mockImplementation(() => ({ openEventId: null, setOpenEventId: mockSetOpenEventId, })) diff --git a/apps/pwa/src/components/EventDetailPopover.tsx b/apps/pwa/src/components/EventDetailPopover.tsx new file mode 100644 index 0000000..c6411f4 --- /dev/null +++ b/apps/pwa/src/components/EventDetailPopover.tsx @@ -0,0 +1,379 @@ +/** + * EventDetailPopover — read-only event detail overlay (Phase 2). + * + * Phase 3 note: This component is built for reuse as the edit surface. + * Reserve the footer action area (marked below) — Phase 3 adds edit/delete here (D-08). + * + * Rendering modes: + * 1. Driven by Zustand openEventId + TanStack Query cache (primary mode) + * 2. Passed calendarEvent prop by Schedule-X customComponents.eventModal + * + * Responsive: + * - Phone (≤767px): bottom sheet (slides from bottom) + * - Tablet/desktop (≥768px): anchored popover (max-width 360px) + * + * Security: T-02e-01 — all event fields rendered as plain-text JSX children. + * NEVER inject raw HTML for any event field. All fields are plain-text JSX children. + * + * Accessibility: + * - Focus trap while open + * - Escape closes and returns focus to triggering element + * - Close button: aria-label="Close", min 44px touch target + * - aria-modal="true", role="dialog" + */ + +import { useEffect, useRef } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { MapPin } from 'lucide-react' +import { useCalendarStore } from '../store/calendarStore.js' +import type { CalendarOccurrence } from '../api/client.js' + +// ── Types ────────────────────────────────────────────────────────────────── + +/** Shape of props passed by Schedule-X customComponents.eventModal */ +interface ScheduleXEventModalProps { + calendarEvent?: { + id?: string | number + title?: string + start?: unknown + end?: unknown + calendarId?: string + location?: string + description?: string + _familySync?: { uid: string; color: string; isShared: boolean } + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Format a start/end pair for display. + * Handles both timed (ISO with tz) and all-day (YYYY-MM-DD) strings. + */ +function formatDateTime(start: string, end: string, allDay: boolean): string { + if (allDay) { + // YYYY-MM-DD — format as a date without time + try { + const d = new Date(start + 'T00:00:00') + return d.toLocaleDateString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'long', + day: 'numeric', + }) + } catch { + return start + } + } + // Timed — parse offset-aware ISO string + try { + const startDate = new Date(start) + const endDate = new Date(end) + const dateStr = startDate.toLocaleDateString(undefined, { + weekday: 'short', + month: 'long', + day: 'numeric', + }) + const startTime = startDate.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + }) + const endTime = endDate.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + }) + return `${dateStr}, ${startTime} – ${endTime}` + } catch { + return start + } +} + +// ── Component ────────────────────────────────────────────────────────────── + +/** + * EventDetailPopover renders when openEventId is non-null (Zustand) or when + * Schedule-X passes a calendarEvent prop (customComponents.eventModal mode). + * + * In Schedule-X modal mode the component receives props from the library. + * In standalone mode it resolves the event from TanStack Query cache. + */ +export function EventDetailPopover(props: ScheduleXEventModalProps = {}) { + const { openEventId, setOpenEventId } = useCalendarStore() + const queryClient = useQueryClient() + const dialogRef = useRef(null) + + // Resolve the event to display: + // 1. If Schedule-X passed a calendarEvent prop, use it to get the id + // 2. Otherwise use Zustand openEventId + const activeId: string | null = (() => { + if (props.calendarEvent?.id != null) { + return String(props.calendarEvent.id) + } + return openEventId + })() + + // Lookup the occurrence in TanStack Query cache. + // We search all 'events' query entries for a matching id. + const occurrence: CalendarOccurrence | null = (() => { + if (!activeId) return null + // queryClient.getQueriesData returns [{queryKey, data}] entries + const allEntries = queryClient.getQueriesData<{ occurrences: CalendarOccurrence[] }>({ + queryKey: ['events'], + }) + for (const [, data] of allEntries) { + if (!data?.occurrences) continue + const found = data.occurrences.find((o) => o.id === activeId) + if (found) return found + } + return null + })() + + // Close handler + const handleClose = () => setOpenEventId(null) + + // Escape key listener — add to document so it works even when focus is trapped + useEffect(() => { + if (!activeId) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleClose() + } + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [activeId]) // eslint-disable-line react-hooks/exhaustive-deps + + // Focus trap — when popover opens, focus the dialog + useEffect(() => { + if (activeId && dialogRef.current) { + dialogRef.current.focus() + } + }, [activeId]) + + // Nothing to show + if (!activeId || !occurrence) return null + + // Responsive: detect phone breakpoint + const isPhone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches + + const dialogStyle: React.CSSProperties = isPhone + ? { + // Phone: bottom sheet + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + background: 'var(--color-surface-raised)', + borderRadius: 'var(--space-3) var(--space-3) 0 0', + boxShadow: '0 -4px 24px rgba(0,0,0,0.12)', + padding: 'var(--space-6)', + maxHeight: '80dvh', + overflowY: 'auto', + zIndex: 200, + fontFamily: 'var(--font-family-base)', + } + : { + // Tablet/desktop: centered popover (max-width 360px) + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + background: 'var(--color-surface-raised)', + borderRadius: 'var(--space-2)', + boxShadow: '0 8px 32px rgba(0,0,0,0.16)', + padding: 'var(--space-6)', + width: '100%', + maxWidth: '360px', + maxHeight: '80dvh', + overflowY: 'auto', + zIndex: 200, + fontFamily: 'var(--font-family-base)', + } + + return ( + <> + {/* Backdrop */} +
+ + {/* Dialog */} +
+ {/* Header: close button */} +
+ +
+ + {/* Color chip + title */} +
+
+ + {/* Date/time */} +
+ {/* Plain text — XSS guard */} + {formatDateTime(occurrence.start, occurrence.end, occurrence.allDay)} +
+ + {/* Location (optional) */} + {occurrence.location != null && ( +
+
+ )} + + {/* Description (optional) — max 4 lines then scroll */} + {occurrence.description != null && ( +
+ {/* Plain text child only — XSS guard (T-02e-01) */} + {occurrence.description} +
+ )} + + {/* Calendar name + owner color swatch */} +
+
+ + {/* Phase 3 footer action area — Phase 3 adds edit/delete actions here (D-08) */} + + + ) +} diff --git a/apps/pwa/src/test-setup.ts b/apps/pwa/src/test-setup.ts index f26a713..c1efca1 100644 --- a/apps/pwa/src/test-setup.ts +++ b/apps/pwa/src/test-setup.ts @@ -10,6 +10,9 @@ * from viewport width at store initialisation time. */ +// Extend Vitest's expect with jest-dom matchers (toHaveTextContent, etc.) +import '@testing-library/jest-dom' + // Polyfill window.matchMedia for jsdom. // jsdom does not implement the CSSOM MediaQueryList API. // calendarStore.ts calls window.matchMedia during Zustand create(), so this