/** * EventForm — create/edit event modal overlay (Plan 03-05). * * Rendering modes: * - create: "New Event" form opened via FAB/toolbar (eventFormMode === 'create') * - edit: pre-populated from TanStack Query cache by eventFormUid * * Responsive: * - Phone (≤767px): bottom sheet (full-screen overlay) * - Tablet/desktop (≥768px): centered dialog (max-width 480px) * * Security: T-03-15 — all field values rendered as plain-text JSX children. * NEVER use dangerouslySetInnerHTML for any event field. * * Calendar picker: hidden when member has exactly 1 writable calendar (D-02). * Populated from GET /api/events/writable-calendars (D-03 server-authoritative). * * Writes: 202 optimistic-accept (D-05/D-12). Enqueued to outbox by the API. * On success: form closes. SyncStateToast feedback lands in Plan 03-06. * * Accessibility: * - role="dialog", aria-modal="true", aria-label="New Event"/"Edit Event" * - Focus moves to Title input on open * - Focus trap: Tab/Shift+Tab cycle focus within dialog; never reaches background (WR-07) * - Escape / backdrop click closes form * - All-day toggle: role="switch", aria-checked * - Recurrence: setTitle(e.target.value)} style={{ ...inputStyle, ...(errors.title ? { borderColor: 'var(--color-destructive)' } : {}), }} /> {errors.title && (
{/* Plain text — XSS guard (T-03-15) */} {errors.title}
)} {/* All-day toggle */}
All day
{/* Start date / time */}
{ const newStart = e.target.value; // D-04: recompute end to preserve duration when start date changes if (allDay) { setEndDate(computeNewAllDayEnd(newStart, startDate, endDate)); } else { const { endDate: ed, endTime: et } = computeNewTimedEnd( newStart, startTime, startDate, startTime, endDate, endTime, ); setEndDate(ed); setEndTime(et); } setStartDate(newStart); }} style={inputStyle} />
{!allDay && (
{ const newTime = e.target.value; // D-04: recompute end to preserve duration when start time changes (timed only) const { endDate: ed, endTime: et } = computeNewTimedEnd( startDate, newTime, startDate, startTime, endDate, endTime, ); setEndDate(ed); setEndTime(et); setStartTime(newTime); }} style={inputStyle} />
)}
{/* End date / time */}
setEndDate(e.target.value)} style={{ ...inputStyle, ...(errors.endTime ? { borderColor: 'var(--color-destructive)' } : {}), }} />
{!allDay && (
setEndTime(e.target.value)} style={{ ...inputStyle, ...(errors.endTime ? { borderColor: 'var(--color-destructive)' } : {}), }} />
)}
{errors.endTime && (
{/* Plain text — XSS guard (T-03-15) */} {errors.endTime}
)} {/* Calendar picker (D-02: hidden when only 1 writable calendar) */} {showPicker && (
)} {/* Recurrence picker (D-11: whole-series only). WR-01: disabled in edit mode — the occurrence contract does not expose the event's recurrence, so the form cannot show/change it without risking a silent reset. Editing recurrence is deferred until the contract exposes it; the existing RRULE is preserved server-side on edit. */}
{/* WR-01: surface the v1 constraint so a user editing a recurring event is not silently surprised that the schedule is locked. Additive helper text only — the existing RRULE is preserved server-side on edit. */} {eventFormMode === 'edit' && (
{/* Plain text — XSS guard (T-03-15) */} Repeat can't be changed yet — edits keep the existing schedule.
)}
{/* Phase 11: Reminder picker (allDay-aware swap, D-01/D-02/D-03/D-07/D-08). NOT disabled in edit mode — reminders are editable (unlike Repeat/WR-01). */}
{/* Helper text: shown in edit mode when value is __custom__ or synthetic off-list (D-07) */} {/* WR-03: gate on the ACTIVE preset set only — not both — so a timed event with a value that happens to be in the allDay set still shows the off-list helper text */} {eventFormMode === 'edit' && (reminderValue === '__custom__' || (reminderValue !== '__none__' && !(allDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS).has( parseInt(reminderValue, 10), ) && Number.isFinite(parseInt(reminderValue, 10)))) && (
{/* Plain text — XSS guard (T-11-10 / T-03-15) */} Custom reminder kept — select a preset to replace it.
)}
{/* D-06: Recurrence bound control — shown only when recurrence ≠ 'none' in create mode */} {eventFormMode !== 'edit' && recurrence !== 'none' && (
{/* "On date" — date input */} {recurrenceBound === 'until' && (
setRecurrenceUntil(e.target.value)} style={{ ...inputStyle, ...(errors.recurrenceBound ? { borderColor: 'var(--color-destructive)' } : {}), }} />
)} {/* "After N times" — number input */} {recurrenceBound === 'count' && (
{ // WR-03: parseInt + Number.isFinite guard. Number('') === 0 and // Number('-'/'e') === NaN both previously slipped through; coerce any // non-finite intermediate to 0 so the validate() guard catches it. const n = parseInt(e.target.value, 10); setRecurrenceCount(Number.isFinite(n) ? n : 0); }} style={{ ...inputStyle, ...(errors.recurrenceBound ? { borderColor: 'var(--color-destructive)' } : {}), }} />
)} {/* Inline validation error (T-06-06-input) */} {errors.recurrenceBound && (
{/* Plain text — XSS guard (T-03-15) */} {errors.recurrenceBound}
)}
)} {/* Location */}
setLocation(e.target.value)} style={inputStyle} />
{/* Description */}