Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
450 lines
15 KiB
TypeScript
450 lines
15 KiB
TypeScript
/**
|
||
* 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, Edit2, Trash2 } 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 (IANA-annotated ISO e.g. '2026-06-18T08:00:00-04:00[America/Toronto]')
|
||
* and all-day ('YYYY-MM-DD') strings.
|
||
*
|
||
* The IANA bracket suffix '[Zone]' is stripped before passing to new Date() because
|
||
* the built-in Date constructor cannot parse it and returns Invalid Date (BUG 3).
|
||
*/
|
||
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.
|
||
// Strip trailing IANA bracket e.g. '[America/Toronto]' before passing to new Date():
|
||
// new Date() cannot parse the bracket notation and returns Invalid Date.
|
||
try {
|
||
const cleanStart = start.replace(/\[[^\]]*\]$/, '');
|
||
const cleanEnd = end.replace(/\[[^\]]*\]$/, '');
|
||
const startDate = new Date(cleanStart);
|
||
const endDate = new Date(cleanEnd);
|
||
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 setEventForm = useCalendarStore((s) => s.setEventForm);
|
||
const setDeleteDialog = useCalendarStore((s) => s.setDeleteDialog);
|
||
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 (T-02e-01).
|
||
Show 'Family' for shared-calendar events; owner display name for personal
|
||
events; fall back to calendarName when ownerName is null. */}
|
||
{occurrence.isShared ? 'Family' : (occurrence.ownerName ?? occurrence.calendarName)}
|
||
</div>
|
||
|
||
{/* Phase 3 footer: Edit / Delete actions (D-10) */}
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginTop: 'var(--space-4)',
|
||
paddingTop: 'var(--space-3)',
|
||
borderTop: '1px solid var(--color-border-subtle)',
|
||
}}
|
||
>
|
||
{/* Edit button — ghost style, left-aligned */}
|
||
<button
|
||
aria-label="Edit event"
|
||
onClick={() => {
|
||
setEventForm(true, 'edit', occurrence.uid);
|
||
setOpenEventId(null);
|
||
}}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
minWidth: '44px',
|
||
minHeight: '44px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 'var(--space-1)',
|
||
fontSize: 'var(--text-label-size)',
|
||
fontWeight: 'var(--text-label-weight)',
|
||
color: 'var(--color-text-primary)',
|
||
borderRadius: 'var(--space-1)',
|
||
padding: '0 var(--space-2)',
|
||
fontFamily: 'var(--font-family-base)',
|
||
}}
|
||
>
|
||
<Edit2 size={16} aria-hidden="true" />
|
||
{/* Plain text — XSS guard */}
|
||
Edit
|
||
</button>
|
||
|
||
{/* Delete button — ghost style, right-aligned, destructive color */}
|
||
<button
|
||
aria-label="Delete event"
|
||
onClick={() => {
|
||
setDeleteDialog(true, occurrence.uid);
|
||
}}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
minWidth: '44px',
|
||
minHeight: '44px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 'var(--space-1)',
|
||
fontSize: 'var(--text-label-size)',
|
||
fontWeight: 'var(--text-label-weight)',
|
||
color: 'var(--color-destructive)',
|
||
borderRadius: 'var(--space-1)',
|
||
padding: '0 var(--space-2)',
|
||
fontFamily: 'var(--font-family-base)',
|
||
}}
|
||
>
|
||
<Trash2 size={16} aria-hidden="true" />
|
||
{/* Plain text — XSS guard */}
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|