mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
fix: give tasks in the phone day view a full row with a tappable circle and drop the empty all-day block
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { CalendarDayView } from '../calendar-day-view';
|
||||
import type { Calendar, CalendarEvent, CalendarTask } from '@/lib/jmap/types';
|
||||
|
||||
// CAL-27 follow-up: the phone day view drew tasks as 10px chips with a
|
||||
// circle too small to tap, and a busy neighbouring day left an empty
|
||||
// all-day block of ~150px above the hours of a day with none.
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
|
||||
|
||||
const holiday = {
|
||||
id: 'ev-1', calendarIds: { 'cal-1': true }, title: 'Holiday', start: '2026-09-10T00:00:00',
|
||||
duration: 'P1D', showWithoutTime: true,
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
const task = {
|
||||
id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { 'cal-1': true },
|
||||
progress: 'needs-action', priority: 0, due: '2026-09-09', showWithoutTime: true,
|
||||
} as unknown as CalendarTask;
|
||||
|
||||
function renderDay(isMobile: boolean, tasks: CalendarTask[] = [task]) {
|
||||
return render(
|
||||
<CalendarDayView
|
||||
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
|
||||
windowKey="day:2026-09-08"
|
||||
rangeStart={new Date(2026, 8, 8)}
|
||||
rangeEnd={new Date(2026, 8, 10)}
|
||||
selectedDate={new Date(2026, 8, 9)}
|
||||
events={[holiday]}
|
||||
calendars={calendars}
|
||||
tasks={tasks}
|
||||
onSelectEvent={vi.fn()}
|
||||
onCreateAtTime={vi.fn()}
|
||||
isMobile={isMobile}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('day view on a phone', () => {
|
||||
it('gives each task a full row with a 44px target for the circle', () => {
|
||||
renderDay(true);
|
||||
const chip = document.querySelector<HTMLElement>('[data-calendar-task="t1"]')!;
|
||||
expect(chip).toHaveClass('min-h-11', 'text-xs');
|
||||
expect(within(chip).getByRole('button', { name: /mark_complete|Mark as complete/i })).toHaveClass('w-11', 'self-stretch');
|
||||
});
|
||||
|
||||
it('keeps the small shared chip on a wide screen', () => {
|
||||
renderDay(false);
|
||||
const chip = document.querySelector<HTMLElement>('[data-calendar-task="t1"]')!;
|
||||
expect(chip).toHaveClass('h-full', 'text-[10px]');
|
||||
});
|
||||
|
||||
it('sizes the all-day row by the day in view, not by a busy neighbour', () => {
|
||||
renderDay(true, []);
|
||||
const today = document.querySelector<HTMLElement>('[data-day="2026-09-09"]')!;
|
||||
const next = document.querySelector<HTMLElement>('[data-day="2026-09-10"]')!;
|
||||
// The day in view has nothing all-day: no empty block under its date.
|
||||
expect(within(today).queryByText(/all_day|All day/i)).toBeNull();
|
||||
expect(today).not.toHaveClass('h-0');
|
||||
// The neighbour's all-day event takes no height of its own.
|
||||
expect(within(next).getByRole('button', { name: /Holiday/ })).toBeInTheDocument();
|
||||
expect(next).toHaveClass('h-0', 'min-h-full', 'overflow-hidden');
|
||||
expect(screen.getAllByRole('columnheader')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -197,14 +197,6 @@ export function CalendarDayView({
|
||||
return map;
|
||||
}, [days, eventsByDay, hours]);
|
||||
|
||||
const hasAllDayArea = useMemo(
|
||||
() => days.some((day) => {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
return (eventsByDay.get(key)?.allDay.length ?? 0) > 0 || (tasksByDay.get(key)?.length ?? 0) > 0;
|
||||
}),
|
||||
[days, eventsByDay, tasksByDay],
|
||||
);
|
||||
|
||||
const scrollToFocus = useCallback(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
@@ -232,6 +224,10 @@ export function CalendarDayView({
|
||||
// not used: browsers re-snap on their own when columns are prepended,
|
||||
// which would double the scroll correction.)
|
||||
const visibleKeyRef = useRef<string | null>(null);
|
||||
// The day in view: its all-day events and tasks size the row under the
|
||||
// dates, so a busy neighbour doesn't leave an empty block above the hours.
|
||||
const [visibleKey, setVisibleKey] = useState(() => dayKey(focus.date));
|
||||
useEffect(() => { setVisibleKey(dayKey(focus.date)); }, [focus.date, focus.nonce]);
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
const snapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const handleStripScroll = useCallback(() => {
|
||||
@@ -255,6 +251,7 @@ export function CalendarDayView({
|
||||
const key = dayKey(day);
|
||||
if (key === visibleKeyRef.current) return;
|
||||
visibleKeyRef.current = key;
|
||||
setVisibleKey(key);
|
||||
onVisibleDateChange?.(day);
|
||||
});
|
||||
}, [colWidth, colCount, days, onVisibleDateChange]);
|
||||
@@ -352,7 +349,16 @@ export function CalendarDayView({
|
||||
const allDayEvents = eventsByDay.get(key)?.allDay ?? [];
|
||||
const dayTasks = tasksByDay.get(key) ?? [];
|
||||
return (
|
||||
<div key={key} data-day={key} className="min-w-0 border-e border-border last:border-e-0">
|
||||
<div
|
||||
key={key}
|
||||
data-day={key}
|
||||
className={cn(
|
||||
"min-w-0 border-e border-border last:border-e-0",
|
||||
// Other days take no height of their own: they fill the
|
||||
// row the day in view sets, clipped while swiping past.
|
||||
key !== visibleKey && "h-0 min-h-full overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn("flex flex-col items-start gap-0.5 px-3 pt-2 pb-1.5", isMobile && "px-2")}
|
||||
role="columnheader"
|
||||
@@ -376,7 +382,7 @@ export function CalendarDayView({
|
||||
<PluginDayLabel surface="calendar-day-header" date={key} className="w-full" />
|
||||
</div>
|
||||
|
||||
{hasAllDayArea && (
|
||||
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
||||
<div
|
||||
className="px-4 py-2 border-t border-border"
|
||||
onContextMenu={onContextMenuEmpty ? (e) => {
|
||||
@@ -414,12 +420,13 @@ export function CalendarDayView({
|
||||
<div className="space-y-0.5">
|
||||
{/* The same chip as the month and week grids. */}
|
||||
{dayTasks.map((task) => (
|
||||
<div key={task.id} className="h-5">
|
||||
<div key={task.id} className={isMobile ? undefined : "h-5"}>
|
||||
<CalendarTaskChip
|
||||
task={task}
|
||||
calendar={calendars.find((c) => task.calendarIds[c.id])}
|
||||
onToggleComplete={onToggleTaskComplete}
|
||||
onSelect={onSelectTask}
|
||||
touch={isMobile}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -10,6 +10,8 @@ interface CalendarTaskChipProps {
|
||||
calendar?: Calendar;
|
||||
onToggleComplete?: (task: CalendarTask) => void;
|
||||
onSelect?: (task: CalendarTask) => void;
|
||||
/** A full row for a finger: 44px tall, a bigger circle with a 44px hit area (phone day view). */
|
||||
touch?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -17,7 +19,7 @@ interface CalendarTaskChipProps {
|
||||
* A task on its due day in the month and week grids (#1107). The circle
|
||||
* toggles completion; the title opens the task in the editor.
|
||||
*/
|
||||
export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, className }: CalendarTaskChipProps) {
|
||||
export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, touch = false, className }: CalendarTaskChipProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const isCompleted = task.progress === "completed";
|
||||
const color = calendar?.color || "#3b82f6";
|
||||
@@ -26,30 +28,39 @@ export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, c
|
||||
return (
|
||||
<div
|
||||
data-calendar-task={task.id}
|
||||
className={cn("h-full rounded text-[10px] font-medium flex items-center overflow-hidden", className)}
|
||||
className={cn(
|
||||
"rounded font-medium flex items-center overflow-hidden",
|
||||
touch ? "min-h-11 text-xs" : "h-full text-[10px]",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: `${color}20`, borderInlineStart: `3px solid ${color}` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onToggleComplete?.(task); }}
|
||||
className="group h-full ps-1 pe-0.5 flex items-center flex-shrink-0"
|
||||
className={cn(
|
||||
"group flex items-center flex-shrink-0",
|
||||
touch ? "self-stretch w-11 justify-center" : "h-full ps-1 pe-0.5",
|
||||
)}
|
||||
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-2.5 h-2.5 rounded-full border flex items-center justify-center",
|
||||
"rounded-full border flex items-center justify-center",
|
||||
touch ? "w-4 h-4" : "w-2.5 h-2.5",
|
||||
isCompleted ? "bg-success border-success text-success-foreground" : "group-hover:bg-background",
|
||||
)}
|
||||
style={isCompleted ? undefined : { borderColor: color }}
|
||||
>
|
||||
{isCompleted && <Check className="h-2 w-2" />}
|
||||
{isCompleted && <Check className={touch ? "h-3 w-3" : "h-2 w-2"} />}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onSelect?.(task); }}
|
||||
className={cn(
|
||||
"h-full flex-1 min-w-0 truncate text-start pe-1 hover:opacity-80",
|
||||
"flex-1 min-w-0 truncate text-start pe-1 hover:opacity-80",
|
||||
touch ? "self-stretch" : "h-full",
|
||||
isCompleted && "line-through text-muted-foreground",
|
||||
)}
|
||||
title={title}
|
||||
|
||||
Reference in New Issue
Block a user