fix: reach calendar and event actions on phones and tablets: a ⋮ in the toolbar and event view, long-press, and row menus that stay visible

This commit is contained in:
Linus Rath
2026-10-28 03:56:23 +01:00
parent bfbf67327c
commit 4dd3101eec
7 changed files with 128 additions and 9 deletions
@@ -453,3 +453,35 @@ describe('CalendarApp creating in the month view', () => {
expect(useCalendarStore.getState().viewMode).toBe('day');
});
});
describe('CalendarApp on touch screens', () => {
it('has a ⋮ with Import, Subscribe and Refresh in the phone toolbar', async () => {
media.mobile = true;
media.desktop = false;
renderApp();
fireEvent.click(screen.getByRole('button', { name: 'toolbar.more' }));
expect(await screen.findByRole('menuitem', { name: 'toolbar.import' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'toolbar.subscribe' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'refresh' })).toBeInTheDocument();
});
it('offers the event menu from a ⋮ in the phone event view', async () => {
media.mobile = true;
media.desktop = false;
setUp([makeEvent()], 'day');
renderApp();
fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]);
const view = await screen.findByRole('dialog', { name: 'Standup' });
fireEvent.click(within(view).getByRole('button', { name: 'toolbar.more' }));
expect(await screen.findByRole('menuitem', { name: 'events.export_ics' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'events.copy_title' })).toBeInTheDocument();
});
it('opens the event menu on a long press', async () => {
renderApp();
const card = screen.getAllByRole('button', { name: /Standup/ })[0];
fireEvent.touchStart(card, { touches: [{ clientX: 40, clientY: 40 }] });
expect(await screen.findByRole('menuitem', { name: 'events.duplicate' }, { timeout: 2000 })).toBeInTheDocument();
fireEvent.touchEnd(card);
});
});
@@ -0,0 +1,31 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { CalendarSidebarPanel } from '../calendar-sidebar-panel';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Calendar } from '@/lib/jmap/types';
// On phones and tablets a calendar's actions (share, colour, default,
// delete) were behind a ⋮ that only showed on hover, and nothing opened on a
// long press.
const work = {
id: 'work', name: 'Work', color: '#3b82f6', sortOrder: 0, isSubscribed: true, isVisible: true,
isDefault: false, includeInAvailability: 'all', timeZone: null, shareWith: null,
myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true, mayShare: true },
} as unknown as Calendar;
const client = { getAccountId: () => 'acct' } as unknown as IJMAPClient;
describe('CalendarSidebarPanel on touch screens', () => {
it('keeps the row ⋮ visible on touch and keyboard focus', () => {
render(<CalendarSidebarPanel calendars={[work]} selectedCalendarIds={['work']} onToggleVisibility={() => {}} client={client} />);
const more = screen.getByRole('button', { name: 'calendar_options' });
expect(more.className).toContain('pointer-coarse:opacity-100');
expect(more.className).toContain('group-focus-within/cal:opacity-100');
});
it('opens a calendar\'s menu on a long press', async () => {
render(<CalendarSidebarPanel calendars={[work]} selectedCalendarIds={['work']} onToggleVisibility={() => {}} onDeleteCalendar={() => {}} client={client} />);
fireEvent.touchStart(screen.getByTestId('calendar-item'), { touches: [{ clientX: 20, clientY: 20 }] });
expect(await screen.findByRole('menuitem', { name: 'delete' }, { timeout: 2000 })).toBeInTheDocument();
});
});
+3
View File
@@ -804,6 +804,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const {
contextMenu: eventContextMenu,
openContextMenu: openEventContextMenu,
openContextMenuFrom: openEventContextMenuFrom,
closeContextMenu: closeEventContextMenu,
menuRef: eventContextMenuRef,
} = useContextMenu<CalendarEvent>();
@@ -2106,6 +2107,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onMore={editEvent ? (anchor) => openEventContextMenuFrom(anchor, editEvent, "end") : undefined}
onDirtyChange={setEventEditorDirty}
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
onPreviewChange={setPendingPreview}
@@ -2261,6 +2263,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onMore={editEvent ? (anchor) => openEventContextMenuFrom(anchor, editEvent, "end") : undefined}
onDirtyChange={setEventEditorDirty}
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
currentUserEmails={currentUserEmails}
+22 -6
View File
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useLongPress } from "@/hooks/use-long-press";
import { useTranslations } from "next-intl";
import { Check, ChevronDown, ChevronRight, Globe, ListTodo, MoreVertical, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "@/components/icons";
import { cn, formatDateTime } from "@/lib/utils";
@@ -114,7 +115,14 @@ export function CalendarSidebarPanel({
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
}, [tasks]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu<Calendar>();
// A long press on a calendar opens its menu, as a right-click does (R7).
const pressedRef = useRef<{ calendar: Calendar; element: HTMLElement } | null>(null);
const longPress = useLongPress(() => {
const pressed = pressedRef.current;
if (pressed) openContextMenuFrom(pressed.element, pressed.calendar);
});
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
// Persisted across mounts so toggle state survives tab switches in the
@@ -276,6 +284,12 @@ export function CalendarSidebarPanel({
<button
onClick={() => onToggleVisibility(cal.id)}
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
{...(hasMenu ? {
onTouchStart: (e: React.TouchEvent<HTMLButtonElement>) => { pressedRef.current = { calendar: cal, element: e.currentTarget }; longPress.onTouchStart(e); },
onTouchMove: longPress.onTouchMove,
onTouchEnd: longPress.onTouchEnd,
onTouchCancel: longPress.onTouchCancel,
} : {})}
role="checkbox"
aria-checked={isVisible}
data-testid="calendar-item"
@@ -327,10 +341,12 @@ export function CalendarSidebarPanel({
{hasMenu && (
<button
type="button"
onClick={(e) => openContextMenu(e, cal)}
onClick={(e) => openContextMenuFrom(e.currentTarget, cal, "end")}
aria-haspopup="menu"
className={cn(
"absolute end-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-opacity",
"opacity-0 group-hover/cal:opacity-100 focus-visible:opacity-100",
// Hover shows it; keyboard focus and touch screens always do (R7).
"opacity-0 group-hover/cal:opacity-100 group-focus-within/cal:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100",
contextMenu.isOpen && contextMenu.data?.id === cal.id && "opacity-100"
)}
aria-label={t('calendar_options', { name: cal.name })}
@@ -474,7 +490,7 @@ export function CalendarSidebarPanel({
<button
type="button"
onClick={onAdd}
className="p-0.5 rounded text-muted-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:text-foreground hover:bg-muted transition-opacity"
className="p-0.5 rounded text-muted-foreground opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 hover:text-foreground hover:bg-muted transition-opacity"
title={tMgmt('add_calendar')}
aria-label={tMgmt('add_calendar')}
>
@@ -547,7 +563,7 @@ export function CalendarSidebarPanel({
onCreateCalendar();
}
}}
className="ms-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
className="ms-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
title={tMgmt('add_calendar')}
>
<Plus className="w-3 h-3" />
+1
View File
@@ -244,6 +244,7 @@ export function CalendarToolbar({
</Button>
</>
)}
{moreButton}
</div>
{/* Row 2: View switcher pills + calendar toggle */}
+23 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState, type CSSProperties, type DragEvent } from "react";
import { useCallback, useRef, useState, type CSSProperties, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -10,6 +10,7 @@ import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participan
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { useSettingsStore } from "@/stores/settings-store";
import { readableTextOn } from "@/lib/color-transform";
import { useLongPress } from "@/hooks/use-long-press";
interface EventCardProps {
event: CalendarEvent;
@@ -149,6 +150,24 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
const handleContextMenu = onContextMenu ? (e: React.MouseEvent) => onContextMenu(e, event) : undefined;
// Touch screens have no right-click: a long press opens the same menu,
// at the finger (R7).
const pressedRef = useRef<HTMLElement | null>(null);
const longPress = useLongPress(({ clientX, clientY }) => {
const target = pressedRef.current;
if (!onContextMenu || !target) return;
onContextMenu({
type: "contextmenu", clientX, clientY, detail: 1, currentTarget: target, target,
preventDefault: () => {}, stopPropagation: () => {},
} as unknown as React.MouseEvent, event);
}, !!onContextMenu);
const touchProps = onContextMenu ? {
onTouchStart: (e: React.TouchEvent<HTMLElement>) => { pressedRef.current = e.currentTarget; longPress.onTouchStart(e); },
onTouchMove: longPress.onTouchMove,
onTouchEnd: longPress.onTouchEnd,
onTouchCancel: longPress.onTouchCancel,
} : {};
if (variant === "chip") {
return (
<button
@@ -156,6 +175,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
{...touchProps}
aria-label={ariaLabel}
{...dragProps}
className={cn(
@@ -179,6 +199,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
{...touchProps}
aria-label={ariaLabel}
{...dragProps}
className={cn(
@@ -207,6 +228,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
{...touchProps}
aria-label={ariaLabel}
{...dragProps}
data-calendar-event
+16 -2
View File
@@ -6,7 +6,8 @@ import { toast } from "@/stores/toast-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LinkifiedText } from "@/components/ui/linkified-text";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle } from "@/components/icons";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle, MoreVertical } from "@/components/icons";
import { IconButton } from "@/components/ui/icon-button";
import { format, parseISO, addHours, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
import { AlertListEditor, AlertSummary, formatEventAlertsLabel } from "./alert-list-editor";
@@ -75,6 +76,8 @@ interface EventModalProps {
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
onClose: () => void;
onPreviewChange?: (preview: PendingEventPreview | null) => void;
/** Opens the event's action menu (export, copy, ...) under the ⋮ of the event view. */
onMore?: (anchor: HTMLElement) => void;
/** Told whether the form holds changes that aren't saved, so the page can ask before it drops them. */
onDirtyChange?: (dirty: boolean) => void;
currentUserEmails?: string[];
@@ -252,6 +255,7 @@ export function EventModal({
onRsvp,
onClose,
onPreviewChange,
onMore,
onDirtyChange,
currentUserEmails = [],
isSubscriptionCalendar,
@@ -961,8 +965,13 @@ export function EventModal({
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? mobileRootClass : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<div className="flex items-center justify-between gap-2 px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
{onMore && (
<IconButton label={t("toolbar.more")} aria-haspopup="menu" className="ms-auto flex-shrink-0" onClick={(e) => onMore(e.currentTarget)}>
<MoreVertical />
</IconButton>
)}
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
@@ -1091,6 +1100,11 @@ export function EventModal({
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">{eventCalendar.name}</p>
)}
</div>
{onMore && (
<IconButton label={t("toolbar.more")} aria-haspopup="menu" className="flex-shrink-0 mt-0.5" onClick={(e) => onMore(e.currentTarget)}>
<MoreVertical />
</IconButton>
)}
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>