feat: disable click when exiting select list

This commit is contained in:
Lucas
2026-09-21 15:58:42 +02:00
parent d3a783f77f
commit b74857d7f6
5 changed files with 106 additions and 48 deletions
@@ -118,3 +118,8 @@
top: auto; top: auto;
bottom: calc(100% + var(--space-1)); bottom: calc(100% + var(--space-1));
} }
.choice-select__listbox--portal {
position: fixed;
z-index: 1000; /* clear of any local stacking contexts in the panel */
}
+72 -19
View File
@@ -1,5 +1,6 @@
// ChoiceSelect.tsx // ChoiceSelect.tsx
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { TriangleDownIcon } from '../../assets/icons/Triangle'; import { TriangleDownIcon } from '../../assets/icons/Triangle';
import './ChoiceSelect.css'; import './ChoiceSelect.css';
@@ -15,6 +16,9 @@ type ChoiceSelectProps<T extends string> = {
}; };
const LISTBOX_MAX_HEIGHT = 240; // keep in sync with .choice-select__listbox max-height const LISTBOX_MAX_HEIGHT = 240; // keep in sync with .choice-select__listbox max-height
const LISTBOX_GAP = 4; // px, ~= var(--space-1)
type Coords = { top: number; left: number; width: number; fontSize: number };
function ChoiceSelect<T extends string>({ function ChoiceSelect<T extends string>({
value, value,
@@ -29,25 +33,49 @@ function ChoiceSelect<T extends string>({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(0); const [highlight, setHighlight] = useState(0);
const [dropUp, setDropUp] = useState(false); const [dropUp, setDropUp] = useState(false);
const [coords, setCoords] = useState<Coords | null>(null);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const suppressNextClick = useRef(false);
useEffect(() => { useEffect(() => {
function handleClick(e: MouseEvent) { function handleMouseDown(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) { const target = e.target as Node;
setOpen(false); const clickedOutside =
} rootRef.current &&
} !rootRef.current.contains(target) &&
document.addEventListener('mousedown', handleClick); !listboxRef.current?.contains(target);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
// Re-check placement on scroll/resize while open, not just at open-time — if (clickedOutside && open) {
// the trigger's position can change (e.g. inside a scrollable panel). setOpen(false);
suppressNextClick.current = true;
}
}
function handleClickCapture(e: MouseEvent) {
if (suppressNextClick.current) {
e.preventDefault();
e.stopPropagation();
suppressNextClick.current = false;
}
}
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('click', handleClickCapture, true); // capture phase
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('click', handleClickCapture, true);
};
}, [open]);
// Re-check placement/position on scroll/resize while open — the trigger's
// position can change (e.g. inside a scrollable panel), and since the
// listbox is portaled it won't move with it automatically.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const recalc = () => computeDropDirection(); const recalc = () => updatePlacement();
window.addEventListener('scroll', recalc, true); window.addEventListener('scroll', recalc, true);
window.addEventListener('resize', recalc); window.addEventListener('resize', recalc);
return () => { return () => {
@@ -64,23 +92,32 @@ function ChoiceSelect<T extends string>({
onChange(options[nextIndex]); onChange(options[nextIndex]);
}; };
function computeDropDirection() { function updatePlacement() {
const el = triggerRef.current; const el = triggerRef.current;
if (!el) return; if (!el) return;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom; const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top; const spaceAbove = rect.top;
// Prefer opening down. Only flip up if there's not enough room below
// AND there's more room above — otherwise stay down and let it scroll/clip.
const needsFlip = spaceBelow < LISTBOX_MAX_HEIGHT && spaceAbove > spaceBelow; const needsFlip = spaceBelow < LISTBOX_MAX_HEIGHT && spaceAbove > spaceBelow;
setDropUp(needsFlip); setDropUp(needsFlip);
// Capture the trigger's resolved font-size (its cqh has already resolved
// correctly here, since it's still inside the real container tree) and
// carry it as a plain px value into the portal, where cqh would break.
const fontSize = parseFloat(getComputedStyle(el).fontSize);
setCoords({
top: needsFlip ? rect.top - LISTBOX_GAP : rect.bottom + LISTBOX_GAP,
left: rect.left,
width: rect.width,
fontSize,
});
} }
function openList() { function openList() {
if (disabled) return; if (disabled) return;
computeDropDirection(); updatePlacement();
setOpen(true); setOpen(true);
const idx = options.indexOf(value); const idx = options.indexOf(value);
setHighlight(idx >= 0 ? idx : 0); setHighlight(idx >= 0 ? idx : 0);
@@ -149,10 +186,25 @@ function ChoiceSelect<T extends string>({
</span> </span>
</button> </button>
{open && ( {open &&
coords &&
createPortal(
<ul <ul
className={'choice-select__listbox' + (dropUp ? ' choice-select__listbox--up' : '')} ref={listboxRef}
className={
'choice-select__listbox choice-select__listbox--portal' +
(dropUp ? ' choice-select__listbox--up' : '')
}
role="listbox" role="listbox"
style={{
fontSize: coords.fontSize,
left: coords.left,
width: coords.width,
// dropUp needs top: auto + bottom instead, see CSS note below
...(dropUp
? { top: 'auto', bottom: window.innerHeight - coords.top }
: { top: coords.top }),
}}
> >
{options.map((option, i) => ( {options.map((option, i) => (
<li <li
@@ -170,7 +222,8 @@ function ChoiceSelect<T extends string>({
{formatLabel(option)} {formatLabel(option)}
</li> </li>
))} ))}
</ul> </ul>,
document.body
)} )}
</div> </div>
); );
@@ -11,7 +11,7 @@ import LoadingOverlay from '../../../../components/LoadingOverlay/LoadingOverlay
import { InputChannel, OutputChannel } from '../../types'; import { InputChannel, OutputChannel } from '../../types';
import MatrixSection from '../Matrixsection/Matrixsection'; import MatrixSection from '../Matrixsection/Matrixsection';
import GeqSection from '../GeqSection/GeqSection'; import GeqSection from '../GeqSection/GeqSection';
import ChannelSection from '../InputSection/ChannelSection'; import ChannelSection from '../ChannelSection/ChannelSection';
import { DSP408Api } from '../../dsp408'; import { DSP408Api } from '../../dsp408';
import { DSP408 } from '../../../../types'; import { DSP408 } from '../../../../types';
import { SidebarId } from '../../constants'; import { SidebarId } from '../../constants';