fix: right-click menu cut off at the bottom of the window

Closes #9.

It was clamped against a GUESSED height — 230 px with the upload submenu, 110
without. The menu is far taller than that with a selection, so right-clicking on
a row near the bottom hid half the entries, delete among them.

It is measured now: placed above the cursor when it does not fit below, pinned
to the top with its own scrollbar when it fits neither way. Its height depends
on which actions the caller passes, so no constant could have stayed right.
This commit is contained in:
2026-07-31 12:25:37 +02:00
parent c5ac945de0
commit e4217da010
2 changed files with 40 additions and 8 deletions
+36 -6
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
import { useI18n } from '@/lib/i18n';
@@ -38,6 +38,12 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
// which used to dismiss the menu the instant it appeared.)
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
const { t } = useI18n();
const boxRef = useRef<HTMLDivElement>(null);
// Starts at the cursor; the layout effect corrects it once the real size is
// known. Rendering at the cursor first avoids a visible jump for the common
// case where it already fits.
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => { if (menu) setPos({ x: menu.x, y: menu.y }); }, [menu]);
useEffect(() => {
if (!menu) return;
const close = () => onClose();
@@ -50,16 +56,40 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
};
}, [menu, onClose]);
// Position AFTER measuring.
//
// The menu used to be clamped against a guessed height — 230 px when the
// upload submenu was present, 110 otherwise. It is far taller than that with
// a selection, so right-clicking near the bottom of the window cut half the
// entries off. Its height also depends on which actions the caller passes, so
// no constant can be right for long.
//
// Measured instead: if it does not fit below the cursor it is placed above,
// and failing that pinned to the top with its own scrollbar.
useLayoutEffect(() => {
if (!menu || !boxRef.current) return;
const r = boxRef.current.getBoundingClientRect();
const pad = 6;
let x = menu.x;
let y = menu.y;
if (x + r.width > window.innerWidth - pad) {
x = Math.max(pad, window.innerWidth - r.width - pad);
}
if (y + r.height > window.innerHeight - pad) {
// Above the cursor, if there is room there.
y = menu.y - r.height >= pad ? menu.y - r.height : Math.max(pad, window.innerHeight - r.height - pad);
}
setPos({ x, y });
}, [menu, onSendTo]);
if (!menu) return null;
const n = menu.ids.length;
// Keep the menu on-screen near the cursor.
const x = Math.min(menu.x, window.innerWidth - 248);
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
return (
<div
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
style={{ left: x, top: y }}
ref={boxRef}
className="fixed z-[200] min-w-[240px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
style={{ left: pos.x, top: pos.y }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">