Compare commits

..

5 Commits

Author SHA1 Message Date
1daec977ba Merge feature/event-panel-ux: scroll bleed fix, auto-grow description, compact layout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:14:52 +08:00
bb39888d2e Fix issues from QA review: invited editor payload, auto-resize perf, resize-y conflict
C-01: Strip is_starred/recurrence_rule from payload for invited editors
      (not in backend allowlist → would 403). Hide Star checkbox from
      invited editor edit mode entirely.

W-01: Wrap auto-resize in requestAnimationFrame to batch with paint
      cycle and avoid forced reflow on every keystroke.

S-01: Add comment documenting belt-and-suspenders scroll prevention.

S-02: Remove resize-y from textarea (conflicts with auto-grow which
      resets height on keystroke, overriding manual resize).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:13:31 +08:00
43322db5ff Disable month-scroll wheel navigation when event panel is open
Prevents accidental month changes (and lost edits) while scrolling
anywhere on the calendar page with the detail panel visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:02:30 +08:00
11f42ef91e Fix description textarea resize: remove max-height cap blocking drag
max-h-[200px] CSS and the 200px JS cap both prevented the resize
handle from expanding the textarea. Removed both constraints so
auto-grow and manual resize work without ceiling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:58:19 +08:00
a78fb495a2 Improve event panel UX: fix scroll bleed, auto-grow description, compact layout
P0 - Scroll bleed: onWheel stopPropagation on panel root prevents
     wheel events from navigating calendar months while editing.

P1 - Description textarea: auto-grows with content (min 80px, max
     200px), manually resizable via resize-y handle. Applied to both
     EventDetailPanel and EventForm.

P2 - Space utilization: moved All Day checkbox inline above date row,
     combined Recurrence + Star into a 2-col row, description now
     fills remaining vertical space with flex-1.

P3 - Removed duplicate footer Save/Cancel buttons from edit mode
     (header icon buttons are sufficient).

P4 - Description field now shows dash placeholder in view mode when
     empty, consistent with other fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:46:40 +08:00
3 changed files with 119 additions and 96 deletions

View File

@ -187,12 +187,13 @@ export default function CalendarPage() {
return () => cancelAnimationFrame(rafId); return () => cancelAnimationFrame(rafId);
}, [panelOpen]); }, [panelOpen]);
// Scroll wheel navigation in month view // Scroll wheel navigation in month view (disabled when detail panel is open)
useEffect(() => { useEffect(() => {
const el = calendarContainerRef.current; const el = calendarContainerRef.current;
if (!el) return; if (!el) return;
let debounceTimer: ReturnType<typeof setTimeout> | null = null; let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
if (panelOpen) return;
// Skip wheel navigation on touch devices (let them scroll normally) // Skip wheel navigation on touch devices (let them scroll normally)
if ('ontouchstart' in window) return; if ('ontouchstart' in window) return;
const api = calendarRef.current?.getApi(); const api = calendarRef.current?.getApi();
@ -207,7 +208,7 @@ export default function CalendarPage() {
}; };
el.addEventListener('wheel', handleWheel, { passive: false }); el.addEventListener('wheel', handleWheel, { passive: false });
return () => el.removeEventListener('wheel', handleWheel); return () => el.removeEventListener('wheel', handleWheel);
}, []); }, [panelOpen]);
// AW-2: Track visible date range for scoped event fetching // AW-2: Track visible date range for scoped event fetching
// W-02 fix: Initialize from current month to avoid unscoped first fetch // W-02 fix: Initialize from current month to avoid unscoped first fetch

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
@ -284,6 +284,17 @@ export default function EventDetailPanel({
const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null); const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null);
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null); const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
const [locationSearch, setLocationSearch] = useState(''); const [locationSearch, setLocationSearch] = useState('');
const descRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize description textarea to fit content
useEffect(() => {
const el = descRef.current;
if (!el) return;
requestAnimationFrame(() => {
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
});
}, [editState.description, isEditing]);
// Poll lock status in view mode for shared events (Stream A: real-time lock awareness) // Poll lock status in view mode for shared events (Stream A: real-time lock awareness)
// lockInfo is only set from the 423 error path; poll data (viewLockQuery.data) is used directly. // lockInfo is only set from the 423 error path; poll data (viewLockQuery.data) is used directly.
@ -367,11 +378,11 @@ export default function EventDetailPanel({
end_datetime: endDt, end_datetime: endDt,
all_day: data.all_day, all_day: data.all_day,
location_id: data.location_id ? parseInt(data.location_id) : null, location_id: data.location_id ? parseInt(data.location_id) : null,
is_starred: data.is_starred,
recurrence_rule: rule,
}; };
// Invited editors cannot change calendars — omit calendar_id from payload // Invited editors are restricted to the backend allowlist — omit fields they cannot modify
if (!canModifyAsInvitee) { if (!canModifyAsInvitee) {
payload.is_starred = data.is_starred;
payload.recurrence_rule = rule;
payload.calendar_id = data.calendar_id ? parseInt(data.calendar_id) : null; payload.calendar_id = data.calendar_id ? parseInt(data.calendar_id) : null;
} }
@ -539,7 +550,8 @@ export default function EventDetailPanel({
: event?.title || ''; : event?.title || '';
return ( return (
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden"> // onWheel stopPropagation: defence-in-depth with CalendarPage's panelOpen guard to prevent month-scroll bleed
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden" onWheel={(e) => e.stopPropagation()}>
{/* Header */} {/* Header */}
<div className="px-5 py-4 border-b border-border shrink-0"> <div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@ -721,7 +733,7 @@ export default function EventDetailPanel({
</div> </div>
) : (isEditing || isCreating) ? ( ) : (isEditing || isCreating) ? (
/* Edit / Create mode */ /* Edit / Create mode */
<div className="space-y-4"> <div className="flex flex-col gap-3 h-full">
{/* Title (only shown in body for create mode; edit mode has it in header) */} {/* Title (only shown in body for create mode; edit mode has it in header) */}
{isCreating && ( {isCreating && (
<div className="space-y-1"> <div className="space-y-1">
@ -737,18 +749,8 @@ export default function EventDetailPanel({
</div> </div>
)} )}
<div className="space-y-1"> {/* All day + Date row */}
<Label htmlFor="panel-desc">Description</Label> <div className="space-y-2">
<Textarea
id="panel-desc"
value={editState.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Add a description..."
rows={3}
className="text-sm resize-none"
/>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
id="panel-allday" id="panel-allday"
@ -760,9 +762,8 @@ export default function EventDetailPanel({
updateField('end_datetime', formatForInput(editState.end_datetime, checked, '10:00')); updateField('end_datetime', formatForInput(editState.end_datetime, checked, '10:00'));
}} }}
/> />
<Label htmlFor="panel-allday">All day event</Label> <Label htmlFor="panel-allday" className="text-xs">All day</Label>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="panel-start" required>Start</Label> <Label htmlFor="panel-start" required>Start</Label>
@ -788,7 +789,9 @@ export default function EventDetailPanel({
/> />
</div> </div>
</div> </div>
</div>
{/* Calendar + Location row */}
<div className={`grid ${canModifyAsInvitee ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}> <div className={`grid ${canModifyAsInvitee ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
{!canModifyAsInvitee && ( {!canModifyAsInvitee && (
<div className="space-y-1"> <div className="space-y-1">
@ -837,8 +840,9 @@ export default function EventDetailPanel({
</div> </div>
</div> </div>
{/* Recurrence — hidden for invited editors (they can only edit "this" occurrence) */} {/* Recurrence + Star row */}
{!canModifyAsInvitee && ( {!canModifyAsInvitee && (
<div className="grid grid-cols-2 gap-3 items-end">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="panel-recurrence">Recurrence</Label> <Label htmlFor="panel-recurrence">Recurrence</Label>
<Select <Select
@ -854,6 +858,15 @@ export default function EventDetailPanel({
<option value="monthly_date">Monthly (date)</option> <option value="monthly_date">Monthly (date)</option>
</Select> </Select>
</div> </div>
<div className="flex items-center gap-2 pb-2">
<Checkbox
id="panel-starred"
checked={editState.is_starred}
onChange={(e) => updateField('is_starred', (e.target as HTMLInputElement).checked)}
/>
<Label htmlFor="panel-starred" className="text-xs">Starred</Label>
</div>
</div>
)} )}
{editState.recurrence_type === 'every_n_days' && ( {editState.recurrence_type === 'every_n_days' && (
@ -924,23 +937,17 @@ export default function EventDetailPanel({
</div> </div>
)} )}
<div className="flex items-center gap-2"> {/* Description — fills remaining space */}
<Checkbox <div className="flex flex-col flex-1 min-h-0 space-y-1">
id="panel-starred" <Label htmlFor="panel-desc">Description</Label>
checked={editState.is_starred} <Textarea
onChange={(e) => updateField('is_starred', (e.target as HTMLInputElement).checked)} ref={descRef}
id="panel-desc"
value={editState.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Add a description..."
className="text-sm flex-1 min-h-[80px]"
/> />
<Label htmlFor="panel-starred">Star this event</Label>
</div>
{/* Save / Cancel buttons at bottom of form */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={handleEditCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
</Button>
</div> </div>
</div> </div>
) : ( ) : (
@ -1056,15 +1063,17 @@ export default function EventDetailPanel({
</div> </div>
{/* Description — full width */} {/* Description — full width */}
{event?.description && (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider"> <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<AlignLeft className="h-3 w-3" /> <AlignLeft className="h-3 w-3" />
Description Description
</div> </div>
{event?.description ? (
<p className="text-sm whitespace-pre-wrap">{event.description}</p> <p className="text-sm whitespace-pre-wrap">{event.description}</p>
</div> ) : (
<p className="text-sm text-muted-foreground"></p>
)} )}
</div>
{/* Invitee section — view mode */} {/* Invitee section — view mode */}
{event && !event.is_virtual && ( {event && !event.is_virtual && (

View File

@ -1,4 +1,4 @@
import { useState, FormEvent } from 'react'; import { useState, useEffect, useRef, FormEvent } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
@ -141,6 +141,17 @@ export default function EventForm({ event, templateData, templateName, initialSt
const existingLocation = locations.find((l) => l.id === source?.location_id); const existingLocation = locations.find((l) => l.id === source?.location_id);
const [locationSearch, setLocationSearch] = useState(existingLocation?.name || ''); const [locationSearch, setLocationSearch] = useState(existingLocation?.name || '');
// Auto-resize description textarea
const descRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = descRef.current;
if (!el) return;
requestAnimationFrame(() => {
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
});
}, [formData.description]);
const selectableCalendars = calendars.filter((c) => !c.is_system); const selectableCalendars = calendars.filter((c) => !c.is_system);
const buildRecurrenceRule = (): RecurrenceRule | null => { const buildRecurrenceRule = (): RecurrenceRule | null => {
@ -255,10 +266,12 @@ export default function EventForm({ event, templateData, templateName, initialSt
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description</Label>
<Textarea <Textarea
ref={descRef}
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="min-h-[80px] flex-1" placeholder="Add a description..."
className="min-h-[80px] text-sm"
/> />
</div> </div>