fix: keep hover previews closed while an editor, card or dialog is open and after one closes until the pointer moves

This commit is contained in:
Linus Rath
2026-10-28 10:19:02 +01:00
parent e48a0ed8f6
commit 82aeddfdaa
4 changed files with 135 additions and 3 deletions
@@ -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();
});
});
+8 -3
View File
@@ -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; }
@@ -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);
});
});
+72
View File
@@ -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 <body>.
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]);
}