fix: keep the event popover inside the window when a plugin section makes it taller

This commit is contained in:
Linus Rath
2026-10-14 09:39:55 +02:00
parent b519b6aa44
commit 173319120a
2 changed files with 63 additions and 4 deletions
@@ -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(
<EventDetailPopover
event={existing}
calendar={calendars[0]}
anchorRect={new DOMRect(600, 520, 120, 60)}
onEdit={vi.fn()}
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onClose={vi.fn()}
onSaveNote={vi.fn()}
/>,
);
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(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
+22 -4
View File
@@ -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 */}
<div className={cn(
"px-4 py-2 space-y-2.5 overflow-y-auto",
isMobile ? "flex-1" : ""
isMobile ? "flex-1" : "min-h-0"
)} style={isMobile ? undefined : { maxHeight: MAX_HEIGHT - 140 }}>
{/* Date & Time */}
<div className="flex items-start gap-2.5">