feat: disable click when exiting select list
This commit is contained in:
@@ -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 */
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user