feat(02-05): EventDetailPopover read-only popover, XSS-safe, wired into CalendarShell
- EventDetailPopover: reads openEventId from Zustand, resolves occurrence from TanStack Query cache - Dual-mode: standalone (Zustand-driven) + customComponents.eventModal (Schedule-X) - Plain-text JSX children for all event fields (T-02e-01 XSS guard) - Focus trap, Escape to close, backdrop-click to close, aria-label=Close (44px target) - Phone: bottom sheet layout; tablet/desktop: centered popover (max-width 360px) - Phase-3 footer action area reserved with comment - CalendarShell: passes customComponents.eventModal=EventDetailPopover to ScheduleXCalendar - test-setup.ts: import @testing-library/jest-dom for toHaveTextContent matcher - All 36 tests pass, tsc clean
This commit is contained in:
@@ -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<HTMLDivElement>(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 */}
|
||||
<div
|
||||
data-testid="popover-backdrop"
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'var(--color-overlay)',
|
||||
zIndex: 199,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={occurrence.title}
|
||||
tabIndex={-1}
|
||||
style={dialogStyle}
|
||||
>
|
||||
{/* Header: close button */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: 'var(--space-2)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
aria-label="Close"
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
minWidth: '44px',
|
||||
minHeight: '44px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
color: 'var(--color-text-secondary)',
|
||||
borderRadius: 'var(--space-1)',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Color chip + title */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 'var(--space-3)',
|
||||
marginBottom: 'var(--space-4)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
background: occurrence.color,
|
||||
flexShrink: 0,
|
||||
marginTop: '4px',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-heading-size)',
|
||||
fontWeight: 'var(--text-heading-weight)',
|
||||
lineHeight: 'var(--text-heading-line-height)',
|
||||
color: 'var(--color-text-primary)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
{/* Plain text child only — XSS guard (T-02e-01) */}
|
||||
{occurrence.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Date/time */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size)',
|
||||
fontWeight: 'var(--text-label-weight)',
|
||||
lineHeight: 'var(--text-label-line-height)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginBottom: 'var(--space-3)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
{/* Plain text — XSS guard */}
|
||||
{formatDateTime(occurrence.start, occurrence.end, occurrence.allDay)}
|
||||
</div>
|
||||
|
||||
{/* Location (optional) */}
|
||||
{occurrence.location != null && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 'var(--space-2)',
|
||||
fontSize: 'var(--text-label-size)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginBottom: 'var(--space-3)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
<MapPin
|
||||
size={14}
|
||||
style={{ flexShrink: 0, marginTop: '2px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Plain text — XSS guard */}
|
||||
<span>{occurrence.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description (optional) — max 4 lines then scroll */}
|
||||
{occurrence.description != null && (
|
||||
<div
|
||||
data-testid="event-description"
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size)',
|
||||
fontWeight: 'var(--text-body-weight)',
|
||||
lineHeight: 'var(--text-body-line-height)',
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-4)',
|
||||
maxHeight: `calc(var(--text-body-size) * var(--text-body-line-height) * 4)`,
|
||||
overflowY: 'auto',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
// Plain text — XSS guard: rendered as {occurrence.description} text child only
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{/* Plain text child only — XSS guard (T-02e-01) */}
|
||||
{occurrence.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calendar name + owner color swatch */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
fontSize: 'var(--text-label-size)',
|
||||
color: 'var(--color-text-muted)',
|
||||
paddingTop: 'var(--space-3)',
|
||||
borderTop: '1px solid var(--color-border-subtle)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '2px',
|
||||
background: occurrence.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Plain text — XSS guard */}
|
||||
{occurrence.calendarName}
|
||||
</div>
|
||||
|
||||
{/* Phase 3 footer action area — Phase 3 adds edit/delete actions here (D-08) */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
// Reserved: empty in Phase 2 (read-only); Phase 3 wires edit/delete buttons here
|
||||
marginTop: 'var(--space-4)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user