From 4dd3101eec7bb5de8e583bbf4375ce7f330152e7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:56:23 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20reach=20calendar=20and=20event=20actions?= =?UTF-8?q?=20on=20phones=20and=20tablets:=20a=20=E2=8B=AE=20in=20the=20to?= =?UTF-8?q?olbar=20and=20event=20view,=20long-press,=20and=20row=20menus?= =?UTF-8?q?=20that=20stay=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../calendar/__tests__/calendar-app.test.tsx | 32 +++++++++++++++++++ .../__tests__/calendar-sidebar-touch.test.tsx | 31 ++++++++++++++++++ components/calendar/calendar-app.tsx | 3 ++ .../calendar/calendar-sidebar-panel.tsx | 28 ++++++++++++---- components/calendar/calendar-toolbar.tsx | 1 + components/calendar/event-card.tsx | 24 +++++++++++++- components/calendar/event-modal.tsx | 18 +++++++++-- 7 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 components/calendar/__tests__/calendar-sidebar-touch.test.tsx diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx index 0b5ff3c3d..e4fabf21d 100644 --- a/components/calendar/__tests__/calendar-app.test.tsx +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -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); + }); +}); diff --git a/components/calendar/__tests__/calendar-sidebar-touch.test.tsx b/components/calendar/__tests__/calendar-sidebar-touch.test.tsx new file mode 100644 index 000000000..befa7469d --- /dev/null +++ b/components/calendar/__tests__/calendar-sidebar-touch.test.tsx @@ -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( {}} 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( {}} onDeleteCalendar={() => {}} client={client} />); + fireEvent.touchStart(screen.getByTestId('calendar-item'), { touches: [{ clientX: 20, clientY: 20 }] }); + expect(await screen.findByRole('menuitem', { name: 'delete' }, { timeout: 2000 })).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index f7db803f9..cb4f6cc17 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -804,6 +804,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const { contextMenu: eventContextMenu, openContextMenu: openEventContextMenu, + openContextMenuFrom: openEventContextMenuFrom, closeContextMenu: closeEventContextMenu, menuRef: eventContextMenuRef, } = useContextMenu(); @@ -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} diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index bcdbe3f30..aadb7bf51 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -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(); + const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu(); + + // 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(null); // Persisted across mounts so toggle state survives tab switches in the @@ -276,6 +284,12 @@ export function CalendarSidebarPanel({ )} + {moreButton} {/* Row 2: View switcher pills + calendar toggle */} diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index 1113fd0b7..0b4282d8f 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -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(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) => { pressedRef.current = e.currentTarget; longPress.onTouchStart(e); }, + onTouchMove: longPress.onTouchMove, + onTouchEnd: longPress.onTouchEnd, + onTouchCancel: longPress.onTouchCancel, + } : {}; + if (variant === "chip") { return ( @@ -1091,6 +1100,11 @@ export function EventModal({

{eventCalendar.name}

)} + {onMore && ( + onMore(e.currentTarget)}> + + + )}