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 */}