feat(03-05): implement EventForm modal (create/edit)
- Bottom sheet on phone, centered 480px dialog on desktop (EventDetailPopover pattern) - Fields: title, all-day toggle, start/end date/time, recurrence select, location, description - D-02: calendar picker hidden when 1 writable calendar, shown when >1 (from writable-calendars endpoint) - D-11: recurrence presets None/Daily/Weekly/Monthly/Yearly only (whole-series) - Validation: empty title + end-before-start with UI-SPEC error copy - create mode: POST /api/events/create; edit mode: PATCH /api/events/:uid/edit - role=dialog aria-modal=true; focus Title on open; Escape/backdrop close - T-03-15: all values as plain-text JSX children; no dangerouslySetInnerHTML - D-01: last-used calendar URL persisted in localStorage - Auto-fix: vi.hoisted() for mock factory variables (D-03-04-hoisting)
This commit is contained in:
@@ -23,9 +23,21 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
|
||||||
// ── Module mocks ──────────────────────────────────────────────────────────────
|
// ── Module mocks ──────────────────────────────────────────────────────────────
|
||||||
|
// vi.hoisted() is required for variables used inside vi.mock() factory functions
|
||||||
|
// to avoid TDZ (temporal dead zone) issues — decision D-03-04-hoisting.
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockSetEventForm,
|
||||||
|
mockCreateEvent,
|
||||||
|
mockUpdateEvent,
|
||||||
|
mockFetchWritableCalendars,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockSetEventForm: vi.fn(),
|
||||||
|
mockCreateEvent: vi.fn().mockResolvedValue({ uid: 'new-uid-123' }),
|
||||||
|
mockUpdateEvent: vi.fn().mockResolvedValue({ uid: 'edit-uid-456' }),
|
||||||
|
mockFetchWritableCalendars: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
// Mock the calendarStore
|
|
||||||
const mockSetEventForm = vi.fn()
|
|
||||||
let mockEventFormOpen = true
|
let mockEventFormOpen = true
|
||||||
let mockEventFormMode: 'create' | 'edit' = 'create'
|
let mockEventFormMode: 'create' | 'edit' = 'create'
|
||||||
let mockEventFormUid: string | null = null
|
let mockEventFormUid: string | null = null
|
||||||
@@ -39,11 +51,6 @@ vi.mock('../store/calendarStore.js', () => ({
|
|||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock write client calls
|
|
||||||
const mockCreateEvent = vi.fn().mockResolvedValue({ uid: 'new-uid-123' })
|
|
||||||
const mockUpdateEvent = vi.fn().mockResolvedValue({ uid: 'edit-uid-456' })
|
|
||||||
const mockFetchWritableCalendars = vi.fn()
|
|
||||||
|
|
||||||
vi.mock('../api/client.js', () => ({
|
vi.mock('../api/client.js', () => ({
|
||||||
createEvent: mockCreateEvent,
|
createEvent: mockCreateEvent,
|
||||||
updateEvent: mockUpdateEvent,
|
updateEvent: mockUpdateEvent,
|
||||||
|
|||||||
@@ -0,0 +1,715 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* - Escape / backdrop click closes form
|
||||||
|
* - All-day toggle: role="switch", aria-checked
|
||||||
|
* - Recurrence: <select> with labeled options
|
||||||
|
* - 44px minimum touch targets on all interactive elements
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { X, Loader2 } from 'lucide-react'
|
||||||
|
import { useCalendarStore } from '../store/calendarStore.js'
|
||||||
|
import {
|
||||||
|
createEvent,
|
||||||
|
updateEvent,
|
||||||
|
fetchWritableCalendars,
|
||||||
|
type CreateEventPayload,
|
||||||
|
type RecurrencePreset,
|
||||||
|
} from '../api/client.js'
|
||||||
|
import type { CalendarOccurrence } from '../api/client.js'
|
||||||
|
|
||||||
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LAST_CALENDAR_KEY = 'eventForm.lastCalendarUrl'
|
||||||
|
|
||||||
|
function getDefaultStartDate(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultEndDate(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Determine if we're on phone breakpoint. */
|
||||||
|
function isPhoneBreakpoint(): boolean {
|
||||||
|
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read last-used calendar URL from localStorage (D-01). */
|
||||||
|
function readLastCalendarUrl(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(LAST_CALENDAR_KEY)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist last-used calendar URL (D-01). */
|
||||||
|
function writeLastCalendarUrl(url: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_CALENDAR_KEY, url)
|
||||||
|
} catch {
|
||||||
|
// Ignore write failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an ISO date string (possibly with time + offset) into
|
||||||
|
* { date: 'YYYY-MM-DD', time: 'HH:MM' }. Falls back to today/09:00 if malformed.
|
||||||
|
*/
|
||||||
|
function parseDateTime(iso: string): { date: string; time: string } {
|
||||||
|
try {
|
||||||
|
// Strip IANA bracket suffix e.g. '[America/Toronto]'
|
||||||
|
const clean = iso.replace(/\[[^\]]*\]$/, '')
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
|
||||||
|
// All-day date string
|
||||||
|
return { date: iso, time: '09:00' }
|
||||||
|
}
|
||||||
|
const d = new Date(clean)
|
||||||
|
if (isNaN(d.getTime())) throw new Error('Invalid date')
|
||||||
|
const date = d.toISOString().slice(0, 10)
|
||||||
|
const hours = String(d.getHours()).padStart(2, '0')
|
||||||
|
const mins = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return { date, time: `${hours}:${mins}` }
|
||||||
|
} catch {
|
||||||
|
return { date: getDefaultStartDate(), time: '09:00' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function EventForm() {
|
||||||
|
const { eventFormOpen, eventFormMode, eventFormUid, setEventForm } = useCalendarStore()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// ── Resolve event for edit mode ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const occurrence: CalendarOccurrence | null = (() => {
|
||||||
|
if (eventFormMode !== 'edit' || !eventFormUid) return null
|
||||||
|
const allEntries = queryClient.getQueriesData<{ occurrences: CalendarOccurrence[] }>({
|
||||||
|
queryKey: ['events'],
|
||||||
|
})
|
||||||
|
for (const [, data] of allEntries) {
|
||||||
|
if (!data?.occurrences) continue
|
||||||
|
const found = data.occurrences.find((o) => o.uid === eventFormUid || o.id === eventFormUid)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})()
|
||||||
|
|
||||||
|
// ── Writable calendars (D-03) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const { data: writableCalendars = [] } = useQuery({
|
||||||
|
queryKey: ['writableCalendars'],
|
||||||
|
queryFn: fetchWritableCalendars,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
enabled: eventFormOpen,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Form state ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const initStart = occurrence ? parseDateTime(occurrence.start) : { date: getDefaultStartDate(), time: '09:00' }
|
||||||
|
const initEnd = occurrence ? parseDateTime(occurrence.end) : { date: getDefaultEndDate(), time: '10:00' }
|
||||||
|
|
||||||
|
const [title, setTitle] = useState(occurrence?.title ?? '')
|
||||||
|
const [allDay, setAllDay] = useState(occurrence?.allDay ?? false)
|
||||||
|
const [startDate, setStartDate] = useState(initStart.date)
|
||||||
|
const [startTime, setStartTime] = useState(initStart.time)
|
||||||
|
const [endDate, setEndDate] = useState(initEnd.date)
|
||||||
|
const [endTime, setEndTime] = useState(initEnd.time)
|
||||||
|
const [recurrence, setRecurrence] = useState<RecurrencePreset>('none')
|
||||||
|
const [location, setLocation] = useState(occurrence?.location ?? '')
|
||||||
|
const [description, setDescription] = useState(occurrence?.description ?? '')
|
||||||
|
const [calendarUrl, setCalendarUrl] = useState<string>(() => {
|
||||||
|
if (occurrence?.calendarId) {
|
||||||
|
// In edit mode we don't know the URL from occurrence, use last-used or first writable
|
||||||
|
return readLastCalendarUrl() ?? writableCalendars[0]?.url ?? ''
|
||||||
|
}
|
||||||
|
return readLastCalendarUrl() ?? writableCalendars[0]?.url ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync calendarUrl default when writableCalendars loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (writableCalendars.length > 0 && !calendarUrl) {
|
||||||
|
const lastUrl = readLastCalendarUrl()
|
||||||
|
const found = lastUrl ? writableCalendars.find((c) => c.url === lastUrl) : null
|
||||||
|
setCalendarUrl(found ? found.url : writableCalendars[0].url)
|
||||||
|
}
|
||||||
|
}, [writableCalendars]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Reset form when opening (mode may change)
|
||||||
|
useEffect(() => {
|
||||||
|
if (eventFormOpen) {
|
||||||
|
const startParsed = occurrence ? parseDateTime(occurrence.start) : { date: getDefaultStartDate(), time: '09:00' }
|
||||||
|
const endParsed = occurrence ? parseDateTime(occurrence.end) : { date: getDefaultEndDate(), time: '10:00' }
|
||||||
|
setTitle(occurrence?.title ?? '')
|
||||||
|
setAllDay(occurrence?.allDay ?? false)
|
||||||
|
setStartDate(startParsed.date)
|
||||||
|
setStartTime(startParsed.time)
|
||||||
|
setEndDate(endParsed.date)
|
||||||
|
setEndTime(endParsed.time)
|
||||||
|
setRecurrence('none')
|
||||||
|
setLocation(occurrence?.location ?? '')
|
||||||
|
setDescription(occurrence?.description ?? '')
|
||||||
|
}
|
||||||
|
}, [eventFormOpen, eventFormMode, eventFormUid]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Validation state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<{ title?: string; endTime?: string }>({})
|
||||||
|
|
||||||
|
// ── Mutations ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (payload: CreateEventPayload) =>
|
||||||
|
eventFormMode === 'edit' && eventFormUid
|
||||||
|
? updateEvent(eventFormUid, payload)
|
||||||
|
: createEvent(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] })
|
||||||
|
if (calendarUrl) writeLastCalendarUrl(calendarUrl)
|
||||||
|
setEventForm(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleClose = () => setEventForm(false)
|
||||||
|
|
||||||
|
const handleAllDayToggle = () => {
|
||||||
|
const next = !allDay
|
||||||
|
setAllDay(next)
|
||||||
|
if (!next) {
|
||||||
|
// Turning off all-day: restore default times
|
||||||
|
setStartTime('09:00')
|
||||||
|
setEndTime('10:00')
|
||||||
|
}
|
||||||
|
if (next && endDate < startDate) {
|
||||||
|
// All-day ON: advance end date to match start date if it's behind
|
||||||
|
setEndDate(startDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validate = (): boolean => {
|
||||||
|
const newErrors: { title?: string; endTime?: string } = {}
|
||||||
|
|
||||||
|
if (!title.trim()) {
|
||||||
|
newErrors.title = 'Title is required'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allDay) {
|
||||||
|
const startISO = `${startDate}T${startTime}:00`
|
||||||
|
const endISO = `${endDate}T${endTime}:00`
|
||||||
|
if (endISO <= startISO) {
|
||||||
|
newErrors.endTime = 'End time must be after start'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (endDate < startDate) {
|
||||||
|
newErrors.endTime = 'End time must be after start'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
return Object.keys(newErrors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!validate()) return
|
||||||
|
|
||||||
|
const payload: CreateEventPayload = {
|
||||||
|
title: title.trim(),
|
||||||
|
allDay,
|
||||||
|
start: allDay ? startDate : `${startDate}T${startTime}:00`,
|
||||||
|
end: allDay ? endDate : `${endDate}T${endTime}:00`,
|
||||||
|
recurrence,
|
||||||
|
...(location.trim() ? { location: location.trim() } : {}),
|
||||||
|
...(description.trim() ? { description: description.trim() } : {}),
|
||||||
|
...(writableCalendars.length > 1 && calendarUrl ? { calendarUrl } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyboard: Escape to close ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!eventFormOpen) return
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') handleClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [eventFormOpen]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Focus: move to title input on open ─────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (eventFormOpen && titleRef.current) {
|
||||||
|
titleRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [eventFormOpen])
|
||||||
|
|
||||||
|
// ── Early return ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (!eventFormOpen) return null
|
||||||
|
|
||||||
|
// ── Responsive styles ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const isPhone = isPhoneBreakpoint()
|
||||||
|
const label = eventFormMode === 'edit' ? 'Edit Event' : 'New Event'
|
||||||
|
const saveLabel = mutation.isPending
|
||||||
|
? 'Saving…'
|
||||||
|
: eventFormMode === 'edit'
|
||||||
|
? 'Save Changes'
|
||||||
|
: 'Create Event'
|
||||||
|
|
||||||
|
const dialogStyle: React.CSSProperties = isPhone
|
||||||
|
? {
|
||||||
|
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: '90dvh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
zIndex: 200,
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
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: '480px',
|
||||||
|
maxHeight: '90dvh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
zIndex: 200,
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared input style ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
minHeight: '44px',
|
||||||
|
padding: '0 var(--space-3)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--space-1)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
fontSize: 'var(--text-body-size)',
|
||||||
|
fontWeight: 'var(--text-body-weight)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
outline: 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 'var(--text-label-size)',
|
||||||
|
fontWeight: 'var(--text-label-weight)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
marginBottom: 'var(--space-1)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldStyle: React.CSSProperties = {
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorStyle: React.CSSProperties = {
|
||||||
|
fontSize: 'var(--text-label-size)',
|
||||||
|
color: 'var(--color-destructive)',
|
||||||
|
marginTop: 'var(--space-1)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const showPicker = writableCalendars.length > 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
data-testid="event-form-backdrop"
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'var(--color-overlay)',
|
||||||
|
zIndex: 199,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dialog */}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={label}
|
||||||
|
tabIndex={-1}
|
||||||
|
style={dialogStyle}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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 — XSS guard (T-03-15) */}
|
||||||
|
{label}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
borderRadius: 'var(--space-1)',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={20} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title field */}
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
<label htmlFor="event-title" style={labelStyle}>
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="event-title"
|
||||||
|
ref={titleRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Event title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
...(errors.title ? { borderColor: 'var(--color-destructive)' } : {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{errors.title && (
|
||||||
|
<div style={errorStyle}>
|
||||||
|
{/* Plain text — XSS guard (T-03-15) */}
|
||||||
|
{errors.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All-day toggle */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...fieldStyle,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={allDay}
|
||||||
|
onClick={handleAllDayToggle}
|
||||||
|
style={{
|
||||||
|
width: '44px',
|
||||||
|
height: '26px',
|
||||||
|
borderRadius: '13px',
|
||||||
|
background: allDay ? 'var(--color-text-primary)' : 'var(--color-border)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
flexShrink: 0,
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '26px',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '3px',
|
||||||
|
left: allDay ? '21px' : '3px',
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#fff',
|
||||||
|
transition: 'left 0.15s',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-label-size)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={handleAllDayToggle}
|
||||||
|
>
|
||||||
|
All day
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Start date / time */}
|
||||||
|
<div style={{ ...fieldStyle, display: 'flex', gap: 'var(--space-3)' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label htmlFor="event-start-date" style={labelStyle}>
|
||||||
|
Start
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="event-start-date"
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!allDay && (
|
||||||
|
<div style={{ flex: '0 0 110px' }}>
|
||||||
|
<label htmlFor="event-start-time" style={labelStyle}>
|
||||||
|
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="event-start-time"
|
||||||
|
type="time"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End date / time */}
|
||||||
|
<div style={{ ...fieldStyle, display: 'flex', gap: 'var(--space-3)' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label htmlFor="event-end-date" style={labelStyle}>
|
||||||
|
End
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="event-end-date"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
...(errors.endTime ? { borderColor: 'var(--color-destructive)' } : {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!allDay && (
|
||||||
|
<div style={{ flex: '0 0 110px' }}>
|
||||||
|
<label htmlFor="event-end-time" style={labelStyle}>
|
||||||
|
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="event-end-time"
|
||||||
|
type="time"
|
||||||
|
value={endTime}
|
||||||
|
onChange={(e) => setEndTime(e.target.value)}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
...(errors.endTime ? { borderColor: 'var(--color-destructive)' } : {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.endTime && (
|
||||||
|
<div style={{ ...errorStyle, marginTop: '-8px', marginBottom: 'var(--space-4)' }}>
|
||||||
|
{/* Plain text — XSS guard (T-03-15) */}
|
||||||
|
{errors.endTime}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Calendar picker (D-02: hidden when only 1 writable calendar) */}
|
||||||
|
{showPicker && (
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
<label htmlFor="event-calendar" style={labelStyle}>
|
||||||
|
Calendar
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="event-calendar"
|
||||||
|
value={calendarUrl}
|
||||||
|
onChange={(e) => setCalendarUrl(e.target.value)}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
padding: '0 var(--space-3)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{writableCalendars.map((cal) => (
|
||||||
|
<option key={cal.url} value={cal.url}>
|
||||||
|
{/* Plain text — XSS guard (T-03-15) */}
|
||||||
|
{cal.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recurrence picker (D-11: whole-series only) */}
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
<label htmlFor="event-recurrence" style={labelStyle}>
|
||||||
|
Repeat
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="event-recurrence"
|
||||||
|
value={recurrence}
|
||||||
|
onChange={(e) => setRecurrence(e.target.value as RecurrencePreset)}
|
||||||
|
style={{ ...inputStyle, padding: '0 var(--space-3)', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="yearly">Yearly</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
<label htmlFor="event-location" style={labelStyle}>
|
||||||
|
Location
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="event-location"
|
||||||
|
type="text"
|
||||||
|
placeholder="Add location"
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div style={fieldStyle}>
|
||||||
|
<label htmlFor="event-description" style={labelStyle}>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="event-description"
|
||||||
|
placeholder="Add description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
minHeight: '80px',
|
||||||
|
padding: 'var(--space-3)',
|
||||||
|
resize: 'vertical',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: Cancel + Save */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
marginTop: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minHeight: '44px',
|
||||||
|
padding: '0 var(--space-4)',
|
||||||
|
fontSize: 'var(--text-label-size)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
borderRadius: 'var(--space-1)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-text-primary)',
|
||||||
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: mutation.isPending ? 'not-allowed' : 'pointer',
|
||||||
|
minHeight: '44px',
|
||||||
|
padding: '0 var(--space-6)',
|
||||||
|
fontSize: 'var(--text-label-size)',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 'var(--space-1)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
opacity: mutation.isPending ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mutation.isPending && (
|
||||||
|
<Loader2 size={16} aria-hidden="true" style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
)}
|
||||||
|
{/* Plain text — XSS guard (T-03-15) */}
|
||||||
|
{saveLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user