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>
This commit is contained in:
parent
80418172db
commit
a78fb495a2
@ -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 { toast } from 'sonner';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
@ -284,6 +284,15 @@ export default function EventDetailPanel({
|
||||
const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null);
|
||||
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
|
||||
const [locationSearch, setLocationSearch] = useState('');
|
||||
const descRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-resize description textarea to fit content
|
||||
useEffect(() => {
|
||||
const el = descRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
|
||||
}, [editState.description, isEditing]);
|
||||
|
||||
// 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.
|
||||
@ -539,7 +548,7 @@ export default function EventDetailPanel({
|
||||
: event?.title || '';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
|
||||
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden" onWheel={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@ -721,7 +730,7 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
) : (isEditing || isCreating) ? (
|
||||
/* 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) */}
|
||||
{isCreating && (
|
||||
<div className="space-y-1">
|
||||
@ -737,58 +746,49 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-desc">Description</Label>
|
||||
<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">
|
||||
<Checkbox
|
||||
id="panel-allday"
|
||||
checked={editState.all_day}
|
||||
onChange={(e) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
updateField('all_day', checked);
|
||||
updateField('start_datetime', formatForInput(editState.start_datetime, checked, '09:00'));
|
||||
updateField('end_datetime', formatForInput(editState.end_datetime, checked, '10:00'));
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="panel-allday">All day event</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-start" required>Start</Label>
|
||||
<DatePicker
|
||||
variant="input"
|
||||
id="panel-start"
|
||||
mode={editState.all_day ? 'date' : 'datetime'}
|
||||
value={editState.start_datetime}
|
||||
onChange={(v) => updateField('start_datetime', v)}
|
||||
className="text-xs"
|
||||
required
|
||||
{/* All day + Date row */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="panel-allday"
|
||||
checked={editState.all_day}
|
||||
onChange={(e) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
updateField('all_day', checked);
|
||||
updateField('start_datetime', formatForInput(editState.start_datetime, checked, '09:00'));
|
||||
updateField('end_datetime', formatForInput(editState.end_datetime, checked, '10:00'));
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="panel-allday" className="text-xs">All day</Label>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-end">End</Label>
|
||||
<DatePicker
|
||||
variant="input"
|
||||
id="panel-end"
|
||||
mode={editState.all_day ? 'date' : 'datetime'}
|
||||
value={editState.end_datetime}
|
||||
onChange={(v) => updateField('end_datetime', v)}
|
||||
className="text-xs"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-start" required>Start</Label>
|
||||
<DatePicker
|
||||
variant="input"
|
||||
id="panel-start"
|
||||
mode={editState.all_day ? 'date' : 'datetime'}
|
||||
value={editState.start_datetime}
|
||||
onChange={(v) => updateField('start_datetime', v)}
|
||||
className="text-xs"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-end">End</Label>
|
||||
<DatePicker
|
||||
variant="input"
|
||||
id="panel-end"
|
||||
mode={editState.all_day ? 'date' : 'datetime'}
|
||||
value={editState.end_datetime}
|
||||
onChange={(v) => updateField('end_datetime', v)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar + Location row */}
|
||||
<div className={`grid ${canModifyAsInvitee ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
|
||||
{!canModifyAsInvitee && (
|
||||
<div className="space-y-1">
|
||||
@ -837,22 +837,44 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence — hidden for invited editors (they can only edit "this" occurrence) */}
|
||||
{/* Recurrence + Star row */}
|
||||
{!canModifyAsInvitee && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-recurrence">Recurrence</Label>
|
||||
<Select
|
||||
id="panel-recurrence"
|
||||
value={editState.recurrence_type}
|
||||
onChange={(e) => updateField('recurrence_type', e.target.value)}
|
||||
className="text-xs"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="every_n_days">Every X days</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly_nth_weekday">Monthly (nth weekday)</option>
|
||||
<option value="monthly_date">Monthly (date)</option>
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-3 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-recurrence">Recurrence</Label>
|
||||
<Select
|
||||
id="panel-recurrence"
|
||||
value={editState.recurrence_type}
|
||||
onChange={(e) => updateField('recurrence_type', e.target.value)}
|
||||
className="text-xs"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="every_n_days">Every X days</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly_nth_weekday">Monthly (nth weekday)</option>
|
||||
<option value="monthly_date">Monthly (date)</option>
|
||||
</Select>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Star for invited editors (no recurrence row shown) */}
|
||||
{canModifyAsInvitee && (
|
||||
<div className="flex items-center gap-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>
|
||||
)}
|
||||
|
||||
@ -924,23 +946,17 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="panel-starred"
|
||||
checked={editState.is_starred}
|
||||
onChange={(e) => updateField('is_starred', (e.target as HTMLInputElement).checked)}
|
||||
{/* Description — fills remaining space */}
|
||||
<div className="flex flex-col flex-1 min-h-0 space-y-1">
|
||||
<Label htmlFor="panel-desc">Description</Label>
|
||||
<Textarea
|
||||
ref={descRef}
|
||||
id="panel-desc"
|
||||
value={editState.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
placeholder="Add a description..."
|
||||
className="text-sm resize-y flex-1 min-h-[80px] max-h-[200px]"
|
||||
/>
|
||||
<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>
|
||||
) : (
|
||||
@ -1056,15 +1072,17 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
|
||||
{/* Description — full width */}
|
||||
{event?.description && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
<AlignLeft className="h-3 w-3" />
|
||||
Description
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{event.description}</p>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
<AlignLeft className="h-3 w-3" />
|
||||
Description
|
||||
</div>
|
||||
)}
|
||||
{event?.description ? (
|
||||
<p className="text-sm whitespace-pre-wrap">{event.description}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">—</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invitee section — view mode */}
|
||||
{event && !event.is_virtual && (
|
||||
|
||||
@ -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 { toast } from 'sonner';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
@ -141,6 +141,15 @@ export default function EventForm({ event, templateData, templateName, initialSt
|
||||
const existingLocation = locations.find((l) => l.id === source?.location_id);
|
||||
const [locationSearch, setLocationSearch] = useState(existingLocation?.name || '');
|
||||
|
||||
// Auto-resize description textarea
|
||||
const descRef = useRef<HTMLTextAreaElement>(null);
|
||||
useEffect(() => {
|
||||
const el = descRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
|
||||
}, [formData.description]);
|
||||
|
||||
const selectableCalendars = calendars.filter((c) => !c.is_system);
|
||||
|
||||
const buildRecurrenceRule = (): RecurrenceRule | null => {
|
||||
@ -255,10 +264,12 @@ export default function EventForm({ event, templateData, templateName, initialSt
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
ref={descRef}
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="min-h-[80px] flex-1"
|
||||
placeholder="Add a description..."
|
||||
className="min-h-[80px] max-h-[200px] resize-y text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user