fix(ui): stop Preferences redrawing with the main window; unclip the dropdowns

TWO FAULTS, ONE SYMPTOM — 'it refreshes ten times a second and the
buttons cannot be pressed'.

Preferences is a child of the main view, so every cluster spot, CAT push
and decode re-rendered the entire panel. On a busy evening that is
several times a second, and the panel is large enough that the rebuild
outlasts the gap between them: buttons missed their clicks because the
element under the pointer was replaced between the press and the release.
It is memoised now, and the callbacks App hands it hold their identity —
without that the memo compares unequal every time and buys nothing.

The dropdown menu was an absolutely-positioned child, so it was clipped
by whichever scrolling or overflow-hidden box it sat in: the satellite
list showed one entry of eight. It is portalled to the body now,
positioned from the field's rectangle, re-measured while open, and opens
upward when the field is near the bottom of the screen — which is exactly
where these fields tend to be.
This commit is contained in:
2026-08-25 20:04:07 +02:00
parent b53c56d508
commit e8dfa0eaaf
4 changed files with 101 additions and 27 deletions
+16 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
@@ -1524,7 +1524,19 @@ function brandOfBackend(backend: string, kenwoodLink?: string): { brand: string;
}
}
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable, onEditQSO }: Props) {
// SETTINGS DOES NOT RE-RENDER WITH THE MAIN WINDOW.
//
// It is a child of App, and App re-renders on every cluster spot, every CAT
// status push, every decode — several times a second on a busy evening. Each of
// those re-rendered this entire panel, which is large enough that the rebuild
// takes longer than the gap between them: the page looked like it was
// refreshing ten times a second and buttons stopped responding, because the
// element under the pointer was replaced between the press and the release.
//
// memo() cuts that off. It only works if the props hold still, which is why
// App passes callbacks that do not change identity on every render — see the
// useCallback wrappers there.
function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable, onEditQSO }: Props) {
const { t } = useI18n();
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true);
@@ -7716,6 +7728,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
);
}
export const SettingsModal = memo(SettingsModalImpl);
// PortInput — a TCP port field you can actually clear.
//
// Every port box was written as `parseInt(e.target.value) || <default>`. Delete
+40 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown } from 'lucide-react';
import { Input } from './input';
import { cn } from '@/lib/utils';
@@ -36,6 +37,10 @@ export function Combobox({
// otherwise "open" onto COM7 alone.
const [browse, setBrowse] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Where the portalled menu goes. Measured from the field itself, and
// re-measured while it is open, so scrolling the panel underneath does not
// leave the list floating over the wrong row.
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 });
useEffect(() => {
function onDoc(e: MouseEvent) {
@@ -45,6 +50,28 @@ export function Combobox({
return () => document.removeEventListener('mousedown', onDoc);
}, []);
useLayoutEffect(() => {
if (!open) return;
const place = () => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
// Opens UPWARD when there is not enough room below — which is exactly
// where these fields tend to sit, at the bottom of a panel.
const height = 240;
const below = window.innerHeight - r.bottom;
const top = below < height + 8 ? Math.max(4, r.top - height - 4) : r.bottom + 4;
setMenuPos({ top, left: r.left, width: r.width });
};
place();
window.addEventListener('scroll', place, true);
window.addEventListener('resize', place);
return () => {
window.removeEventListener('scroll', place, true);
window.removeEventListener('resize', place);
};
}, [open]);
const filtered = !open ? []
: browse ? options.slice(0, 60)
: options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60);
@@ -121,8 +148,16 @@ export function Combobox({
<ChevronDown className="size-3.5" />
</button>
)}
{open && filtered.length > 0 && (
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card shadow-lg text-xs">
{/* THE MENU IS PORTALLED to the body, and positioned from the field's own
rectangle. As an absolutely-positioned child it was clipped by whichever
scrolling or overflow-hidden box it happened to sit in: in the details
panel it was cut off after the first row, and a list showing one entry
of eight is worse than no list at all. */}
{open && filtered.length > 0 && createPortal(
<div
style={{ position: 'fixed', top: menuPos.top, left: menuPos.left, width: menuPos.width }}
className="z-[100] max-h-60 overflow-auto rounded-md border border-border bg-card shadow-lg text-xs"
>
{filtered.map((o) => (
<button
key={o}
@@ -134,7 +169,8 @@ export function Combobox({
{o}
</button>
))}
</div>
</div>,
document.body,
)}
</div>
);