mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user