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;
bottom: calc(100% + var(--space-1));
}
.choice-select__listbox--portal {
position: fixed;
z-index: 1000; /* clear of any local stacking contexts in the panel */
}
+100 -47
View File
@@ -1,5 +1,6 @@
// ChoiceSelect.tsx
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { TriangleDownIcon } from '../../assets/icons/Triangle';
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_GAP = 4; // px, ~= var(--space-1)
type Coords = { top: number; left: number; width: number; fontSize: number };
function ChoiceSelect<T extends string>({
value,
@@ -29,25 +33,49 @@ function ChoiceSelect<T extends string>({
const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(0);
const [dropUp, setDropUp] = useState(false);
const [coords, setCoords] = useState<Coords | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const suppressNextClick = useRef(false);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
}
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
const target = e.target as Node;
const clickedOutside =
rootRef.current &&
!rootRef.current.contains(target) &&
!listboxRef.current?.contains(target);
if (clickedOutside && open) {
setOpen(false);
suppressNextClick.current = true;
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
}
// Re-check placement on scroll/resize while open, not just at open-time —
// the trigger's position can change (e.g. inside a scrollable panel).
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(() => {
if (!open) return;
const recalc = () => computeDropDirection();
const recalc = () => updatePlacement();
window.addEventListener('scroll', recalc, true);
window.addEventListener('resize', recalc);
return () => {
@@ -64,23 +92,32 @@ function ChoiceSelect<T extends string>({
onChange(options[nextIndex]);
};
function computeDropDirection() {
const el = triggerRef.current;
if (!el) return;
function updatePlacement() {
const el = triggerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const rect = el.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const needsFlip = spaceBelow < LISTBOX_MAX_HEIGHT && spaceAbove > spaceBelow;
setDropUp(needsFlip);
// 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;
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() {
if (disabled) return;
computeDropDirection();
updatePlacement();
setOpen(true);
const idx = options.indexOf(value);
setHighlight(idx >= 0 ? idx : 0);
@@ -149,31 +186,47 @@ function ChoiceSelect<T extends string>({
</span>
</button>
{open && (
<ul
className={'choice-select__listbox' + (dropUp ? ' choice-select__listbox--up' : '')}
role="listbox"
>
{options.map((option, i) => (
<li
key={option}
role="option"
aria-selected={option === value}
className={
'choice-select__option' +
(option === value ? ' choice-select__option--selected' : '') +
(i === highlight ? ' choice-select__option--highlight' : '')
}
onMouseEnter={() => setHighlight(i)}
onClick={() => pick(option)}
>
{formatLabel(option)}
</li>
))}
</ul>
)}
{open &&
coords &&
createPortal(
<ul
ref={listboxRef}
className={
'choice-select__listbox choice-select__listbox--portal' +
(dropUp ? ' choice-select__listbox--up' : '')
}
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) => (
<li
key={option}
role="option"
aria-selected={option === value}
className={
'choice-select__option' +
(option === value ? ' choice-select__option--selected' : '') +
(i === highlight ? ' choice-select__option--highlight' : '')
}
onMouseEnter={() => setHighlight(i)}
onClick={() => pick(option)}
>
{formatLabel(option)}
</li>
))}
</ul>,
document.body
)}
</div>
);
}
export default ChoiceSelect;
export default ChoiceSelect;
@@ -11,7 +11,7 @@ import LoadingOverlay from '../../../../components/LoadingOverlay/LoadingOverlay
import { InputChannel, OutputChannel } from '../../types';
import MatrixSection from '../Matrixsection/Matrixsection';
import GeqSection from '../GeqSection/GeqSection';
import ChannelSection from '../InputSection/ChannelSection';
import ChannelSection from '../ChannelSection/ChannelSection';
import { DSP408Api } from '../../dsp408';
import { DSP408 } from '../../../../types';
import { SidebarId } from '../../constants';