diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx index 843e003d9..323b411a3 100644 --- a/components/calendar/__tests__/calendar-app.test.tsx +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -126,6 +126,18 @@ describe('CalendarApp keyboard shortcuts', () => { fireEvent.keyDown(document.body, { key: 'm', code: 'KeyM' }); expect(useCalendarStore.getState().viewMode).toBe('month'); }); + + // New 6: the preview opened on top of the open editor. + it('opens no hover preview while an editor is open', async () => { + setUp([makeEvent(), makeEvent({ id: 'e2', uid: 'u2', title: 'Retro', start: '2026-10-13T14:00:00' })]); + renderApp(); + fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]); + fireEvent.click(await screen.findByRole('button', { name: 'events.edit' })); + await screen.findByRole('dialog', { name: 'events.edit' }); + fireEvent.mouseEnter(screen.getAllByRole('button', { name: /Retro/ })[0]); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByRole('dialog', { name: 'Retro' })).toBeNull(); + }); }); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index db516779c..7eca930b6 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -91,6 +91,7 @@ import { useCalendarEventSearch } from "@/hooks/use-calendar-event-search"; import { CalendarSearchField, CalendarSearchResults } from "@/components/calendar/calendar-search"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; +import { useHoverPreviewGate } from "@/hooks/use-hover-preview-gate"; import { computeScrollWindow, fixedScrollWindowState, freshScrollWindowState, growScrollWindow, normalizeScrollWindowState, scrollWindowContains, type CalendarFocus, type ScrollViewMode, type ScrollWindowOptions, @@ -842,16 +843,20 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = [closeDetail, openEmptyContextMenu], ); + // No preview over an open editor, card or dialog, nor after one closed + // until the pointer moves. + const canHoverPreview = useHoverPreviewGate(showEventModal || showTaskModal || detailPinned || quickDraft !== null); + const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { if (isMobile) return; if (detailPinnedRef.current) return; if (calendarHoverPreview === 'off') return; if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } - // Don't show hover popover if the sidebar is already open for this event - if (showEventModal && editEvent?.id === event.id) return; + if (!canHoverPreview()) return; if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') { const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000; hoverTimerRef.current = setTimeout(() => { + if (!canHoverPreview()) return; setDetailEvent(event); setDetailAnchorRect(anchorRect); }, ms); @@ -859,7 +864,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setDetailEvent(event); setDetailAnchorRect(anchorRect); } - }, [isMobile, calendarHoverPreview, showEventModal, editEvent]); + }, [isMobile, calendarHoverPreview, canHoverPreview]); const handleHoverLeave = useCallback(() => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } diff --git a/hooks/__tests__/use-hover-preview-gate.test.ts b/hooks/__tests__/use-hover-preview-gate.test.ts new file mode 100644 index 000000000..6de1c03c8 --- /dev/null +++ b/hooks/__tests__/use-hover-preview-gate.test.ts @@ -0,0 +1,43 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { useHoverPreviewGate } from '../use-hover-preview-gate'; + +// The hover preview opened on top of the open editor, and by itself after +// a dialog closed with the pointer resting on an event. + +function move(x: number, y: number) { + window.dispatchEvent(new MouseEvent('pointermove', { clientX: x, clientY: y })); +} + +describe('useHoverPreviewGate', () => { + it('lets a preview open when nothing is open', () => { + const { result } = renderHook(() => useHoverPreviewGate(false)); + expect(result.current()).toBe(true); + }); + + it('holds previews back while an editor is open, and after it closed until the pointer moves', () => { + const { result, rerender } = renderHook(({ open }) => useHoverPreviewGate(open), { initialProps: { open: true } }); + move(100, 100); + expect(result.current()).toBe(false); + rerender({ open: false }); + expect(result.current()).toBe(false); + move(101, 101); + expect(result.current()).toBe(false); + move(140, 120); + expect(result.current()).toBe(true); + }); + + it('holds previews back while a modal dialog is on the page, and after it closed', async () => { + const { result } = renderHook(() => useHoverPreviewGate(false)); + move(10, 10); + const dialog = document.createElement('div'); + dialog.setAttribute('aria-modal', 'true'); + document.body.appendChild(dialog); + expect(result.current()).toBe(false); + dialog.remove(); + await new Promise((r) => setTimeout(r, 0)); + expect(result.current()).toBe(false); + move(60, 60); + expect(result.current()).toBe(true); + }); +}); diff --git a/hooks/use-hover-preview-gate.ts b/hooks/use-hover-preview-gate.ts new file mode 100644 index 000000000..430a2a873 --- /dev/null +++ b/hooks/use-hover-preview-gate.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useRef } from "react"; +import { isContextMenuOpen } from "@/components/ui/context-menu"; + +/** How far the pointer has to move before a preview may open again. */ +const MOVE_THRESHOLD = 4; + +const MODAL_SELECTOR = '[aria-modal="true"]'; + +/** + * Decides whether a hover preview may open. Not while an editor, a card, + * a menu or a dialog is open (`overlayOpen` plus any modal on the page), + * and not after one closed until the pointer really moves: the pointer + * resting where the dialog was otherwise "hovers" the event under it and + * the preview opens by itself. + */ +export function useHoverPreviewGate(overlayOpen: boolean): () => boolean { + const overlayOpenRef = useRef(overlayOpen); + const pointer = useRef<{ x: number; y: number } | null>(null); + // Where the pointer was when previews were held back; null when free. + const heldAt = useRef<{ x: number; y: number } | null>(null); + + const hold = useCallback(() => { + heldAt.current = pointer.current ?? { x: Number.NaN, y: Number.NaN }; + }, []); + + useEffect(() => { + const onMove = (e: PointerEvent | MouseEvent) => { + const at = { x: e.clientX, y: e.clientY }; + pointer.current = at; + const held = heldAt.current; + if (!held) return; + // Position unknown when it was held: the first move frees it. + if (Number.isNaN(held.x) || Math.hypot(at.x - held.x, at.y - held.y) > MOVE_THRESHOLD) { + heldAt.current = null; + } + }; + window.addEventListener("pointermove", onMove, { passive: true }); + return () => window.removeEventListener("pointermove", onMove); + }, []); + + // An editor, card or quick editor of the calendar closing. + useEffect(() => { + const was = overlayOpenRef.current; + overlayOpenRef.current = overlayOpen; + if (was && !overlayOpen) hold(); + }, [overlayOpen, hold]); + + // Any other dialog (a confirm, import, shortcuts) portalled onto . + useEffect(() => { + if (typeof MutationObserver === "undefined") return; + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.removedNodes) { + if (node instanceof Element && (node.matches(MODAL_SELECTOR) || node.querySelector(MODAL_SELECTOR))) { + hold(); + return; + } + } + } + }); + observer.observe(document.body, { childList: true }); + return () => observer.disconnect(); + }, [hold]); + + return useCallback(() => { + if (overlayOpenRef.current || isContextMenuOpen() || document.querySelector(MODAL_SELECTOR)) { + hold(); + return false; + } + return heldAt.current === null; + }, [hold]); +}