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 */
}
+100 -47
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) &&
!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 — function handleClickCapture(e: MouseEvent) {
// the trigger's position can change (e.g. inside a scrollable panel). 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;
const needsFlip = spaceBelow < LISTBOX_MAX_HEIGHT && spaceAbove > spaceBelow;
setDropUp(needsFlip);
// Prefer opening down. Only flip up if there's not enough room below // Capture the trigger's resolved font-size (its cqh has already resolved
// AND there's more room above — otherwise stay down and let it scroll/clip. // correctly here, since it's still inside the real container tree) and
const needsFlip = spaceBelow < LISTBOX_MAX_HEIGHT && spaceAbove > spaceBelow; // carry it as a plain px value into the portal, where cqh would break.
setDropUp(needsFlip); 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,31 +186,47 @@ function ChoiceSelect<T extends string>({
</span> </span>
</button> </button>
{open && ( {open &&
<ul coords &&
className={'choice-select__listbox' + (dropUp ? ' choice-select__listbox--up' : '')} createPortal(
role="listbox" <ul
> ref={listboxRef}
{options.map((option, i) => ( className={
<li 'choice-select__listbox choice-select__listbox--portal' +
key={option} (dropUp ? ' choice-select__listbox--up' : '')
role="option" }
aria-selected={option === value} role="listbox"
className={ style={{
'choice-select__option' + fontSize: coords.fontSize,
(option === value ? ' choice-select__option--selected' : '') + left: coords.left,
(i === highlight ? ' choice-select__option--highlight' : '') width: coords.width,
} // dropUp needs top: auto + bottom instead, see CSS note below
onMouseEnter={() => setHighlight(i)} ...(dropUp
onClick={() => pick(option)} ? { top: 'auto', bottom: window.innerHeight - coords.top }
> : { top: coords.top }),
{formatLabel(option)} }}
</li> >
))} {options.map((option, i) => (
</ul> <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> </div>
); );
} }
export default ChoiceSelect; export default ChoiceSelect;
@@ -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';