From 173319120ad42390c5bc7f55ba2becb74ee2c3a9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:39:55 +0200 Subject: [PATCH] fix: keep the event popover inside the window when a plugin section makes it taller --- .../calendar-plugin-surfaces.test.tsx | 41 +++++++++++++++++++ components/calendar/event-detail-popover.tsx | 26 ++++++++++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx index 06e07cf7d..9c44317d9 100644 --- a/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx +++ b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx @@ -56,6 +56,47 @@ describe('calendar-event-detail', () => { expect(framePropsOf(frame).event).toMatchObject({ id: 'e1', uid: 'uid-1', title: 'Planning', calendarId: 'cal-1' }); }); + it('moves the popover up when the plugin section makes it taller, so it stays in the window', () => { + let resize: (() => void) | null = null; + vi.stubGlobal('ResizeObserver', class { + constructor(cb: () => void) { resize = cb; } + observe() {} + unobserve() {} + disconnect() {} + }); + Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true }); + Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true }); + let height = 200; + const spy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) { + return this.getAttribute('role') === 'dialog' ? height : 0; + }); + try { + offerSlots('p1', ['calendar-event-detail']); + // An event at 14:00 in the week view of a 1280x800 window. + render( + , + ); + const popover = screen.getByRole('dialog'); + expect(popover.style.top).toBe('520px'); + + height = 420; + act(() => resize?.()); + expect(popover.style.top).toBe(`${800 - 420 - 12}px`); + } finally { + spy.mockRestore(); + vi.unstubAllGlobals(); + } + }); + it('shows the event in the modal view, and not in the form', () => { offerSlots('p1', ['calendar-event-detail']); render(); diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 7a54d8f66..4ab0eba77 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -54,13 +54,18 @@ const POPOVER_GAP = 8; const VIEWPORT_MARGIN = 12; const MAX_HEIGHT = 480; +/** As tall as MAX_HEIGHT, but never taller than the window. */ +function maxPopoverHeight(): number { + return Math.max(0, Math.min(MAX_HEIGHT, window.innerHeight - 2 * VIEWPORT_MARGIN)); +} + function computePosition( anchorRect: DOMRect, popoverHeight: number ): { top: number; left: number } { const vw = window.innerWidth; const vh = window.innerHeight; - const clampedHeight = Math.min(popoverHeight, MAX_HEIGHT); + const clampedHeight = Math.min(popoverHeight, maxPopoverHeight()); const clampTop = (top: number) => Math.min(Math.max(VIEWPORT_MARGIN, top), vh - clampedHeight - VIEWPORT_MARGIN); @@ -226,6 +231,19 @@ export function EventDetailPopover({ if (!ready) requestAnimationFrame(() => setReady(true)); }, [anchorRect, noteExpanded, showDeleteConfirm, ready]); + // Content that grows after the popover was placed (a plugin section reports + // its height a moment later) moves it again, so it stays in the window. + useEffect(() => { + const node = popoverRef.current; + if (!node || isMobile || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + const next = computePosition(anchorRect, node.offsetHeight); + setPosition((prev) => (prev && prev.top === next.top && prev.left === next.left ? prev : next)); + }); + observer.observe(node); + return () => observer.disconnect(); + }, [anchorRect, isMobile]); + useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); @@ -302,14 +320,14 @@ export function EventDetailPopover({ "fixed z-[60] bg-background border border-border shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out", isMobile ? "inset-0 rounded-none flex flex-col" - : "rounded-lg" + : "rounded-lg flex flex-col" )} style={isMobile ? { opacity: 1, transform: "none", } : { width: POPOVER_WIDTH, - maxHeight: MAX_HEIGHT, + maxHeight: maxPopoverHeight(), top: position?.top ?? -9999, left: position?.left ?? -9999, opacity: ready ? 1 : 0, @@ -413,7 +431,7 @@ export function EventDetailPopover({ {/* Content */}
{/* Date & Time */}