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:
@@ -31,6 +31,7 @@ import { fetchMe, fetchEvents } from '../api/client.js'
|
|||||||
import { hydrateEvents } from '../lib/hydrateEvents.js'
|
import { hydrateEvents } from '../lib/hydrateEvents.js'
|
||||||
import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js'
|
import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js'
|
||||||
import { useCalendarStore } from '../store/calendarStore.js'
|
import { useCalendarStore } from '../store/calendarStore.js'
|
||||||
|
import { EventDetailPopover } from './EventDetailPopover.js'
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -177,8 +178,14 @@ export function CalendarShell() {
|
|||||||
|
|
||||||
{/* Schedule-X calendar fills available space */}
|
{/* Schedule-X calendar fills available space */}
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
<ScheduleXCalendar calendarApp={calendar} />
|
<ScheduleXCalendar
|
||||||
|
calendarApp={calendar}
|
||||||
|
customComponents={{ eventModal: EventDetailPopover }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* EventDetailPopover — standalone mode driven by Zustand openEventId */}
|
||||||
|
<EventDetailPopover />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ vi.mock('../store/calendarStore.js', () => ({
|
|||||||
|
|
||||||
// ── Fixtures ───────────────────────────────────────────────────────────────────
|
// ── Fixtures ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const TIMED_OCCURRENCE = {
|
import type { CalendarOccurrence } from '../api/client.js'
|
||||||
|
|
||||||
|
const TIMED_OCCURRENCE: CalendarOccurrence = {
|
||||||
id: 'test-uid::2026-06-15T10:00:00',
|
id: 'test-uid::2026-06-15T10:00:00',
|
||||||
uid: 'test-uid',
|
uid: 'test-uid',
|
||||||
calendarId: 1,
|
calendarId: 1,
|
||||||
@@ -47,7 +49,7 @@ const TIMED_OCCURRENCE = {
|
|||||||
description: 'Daily team sync meeting',
|
description: 'Daily team sync meeting',
|
||||||
}
|
}
|
||||||
|
|
||||||
const OCCURRENCE_WITH_HTML = {
|
const OCCURRENCE_WITH_HTML: CalendarOccurrence = {
|
||||||
...TIMED_OCCURRENCE,
|
...TIMED_OCCURRENCE,
|
||||||
id: 'xss-uid::2026-06-15T10:00:00',
|
id: 'xss-uid::2026-06-15T10:00:00',
|
||||||
uid: 'xss-uid',
|
uid: 'xss-uid',
|
||||||
@@ -56,7 +58,7 @@ const OCCURRENCE_WITH_HTML = {
|
|||||||
location: '<img src=x onerror=alert(1)>Room',
|
location: '<img src=x onerror=alert(1)>Room',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLDAY_OCCURRENCE = {
|
const ALLDAY_OCCURRENCE: CalendarOccurrence = {
|
||||||
id: 'allday-uid::2026-06-20',
|
id: 'allday-uid::2026-06-20',
|
||||||
uid: 'allday-uid',
|
uid: 'allday-uid',
|
||||||
calendarId: 2,
|
calendarId: 2,
|
||||||
@@ -81,7 +83,7 @@ import { useCalendarStore } from '../store/calendarStore.js'
|
|||||||
|
|
||||||
function renderPopover(occurrence = TIMED_OCCURRENCE) {
|
function renderPopover(occurrence = TIMED_OCCURRENCE) {
|
||||||
mockOpenEventId = occurrence.id
|
mockOpenEventId = occurrence.id
|
||||||
;(useCalendarStore as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
;(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||||
openEventId: mockOpenEventId,
|
openEventId: mockOpenEventId,
|
||||||
setOpenEventId: mockSetOpenEventId,
|
setOpenEventId: mockSetOpenEventId,
|
||||||
}))
|
}))
|
||||||
@@ -157,7 +159,7 @@ describe('EventDetailPopover', () => {
|
|||||||
|
|
||||||
it('renders nothing when openEventId is null', () => {
|
it('renders nothing when openEventId is null', () => {
|
||||||
mockOpenEventId = null
|
mockOpenEventId = null
|
||||||
;(useCalendarStore as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
;(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||||
openEventId: null,
|
openEventId: null,
|
||||||
setOpenEventId: mockSetOpenEventId,
|
setOpenEventId: mockSetOpenEventId,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
* from viewport width at store initialisation time.
|
* 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.
|
// Polyfill window.matchMedia for jsdom.
|
||||||
// jsdom does not implement the CSSOM MediaQueryList API.
|
// jsdom does not implement the CSSOM MediaQueryList API.
|
||||||
// calendarStore.ts calls window.matchMedia during Zustand create(), so this
|
// calendarStore.ts calls window.matchMedia during Zustand create(), so this
|
||||||
|
|||||||
Reference in New Issue
Block a user