diff --git a/src/components/ChoiceSelect/ChoiceSelect.css b/src/components/ChoiceSelect/ChoiceSelect.css index 33f38a7..cd2e8dc 100644 --- a/src/components/ChoiceSelect/ChoiceSelect.css +++ b/src/components/ChoiceSelect/ChoiceSelect.css @@ -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 */ +} \ No newline at end of file diff --git a/src/components/ChoiceSelect/ChoiceSelect.tsx b/src/components/ChoiceSelect/ChoiceSelect.tsx index 6546a09..374ffea 100644 --- a/src/components/ChoiceSelect/ChoiceSelect.tsx +++ b/src/components/ChoiceSelect/ChoiceSelect.tsx @@ -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 = { }; 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({ value, @@ -29,25 +33,49 @@ function ChoiceSelect({ const [open, setOpen] = useState(false); const [highlight, setHighlight] = useState(0); const [dropUp, setDropUp] = useState(false); + const [coords, setCoords] = useState(null); const rootRef = useRef(null); const triggerRef = useRef(null); + const listboxRef = useRef(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({ 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({ - {open && ( -
    - {options.map((option, i) => ( -
  • setHighlight(i)} - onClick={() => pick(option)} - > - {formatLabel(option)} -
  • - ))} -
- )} + {open && + coords && + createPortal( +
    + {options.map((option, i) => ( +
  • setHighlight(i)} + onClick={() => pick(option)} + > + {formatLabel(option)} +
  • + ))} +
, + document.body + )} ); } -export default ChoiceSelect; +export default ChoiceSelect; \ No newline at end of file diff --git a/src/features/dsp408/components/InputSection/ChannelSection.css b/src/features/dsp408/components/ChannelSection/ChannelSection.css similarity index 100% rename from src/features/dsp408/components/InputSection/ChannelSection.css rename to src/features/dsp408/components/ChannelSection/ChannelSection.css diff --git a/src/features/dsp408/components/InputSection/ChannelSection.tsx b/src/features/dsp408/components/ChannelSection/ChannelSection.tsx similarity index 100% rename from src/features/dsp408/components/InputSection/ChannelSection.tsx rename to src/features/dsp408/components/ChannelSection/ChannelSection.tsx diff --git a/src/features/dsp408/components/DSP408Panel/DSP408Panel.tsx b/src/features/dsp408/components/DSP408Panel/DSP408Panel.tsx index e3c7bfe..98bcd8d 100644 --- a/src/features/dsp408/components/DSP408Panel/DSP408Panel.tsx +++ b/src/features/dsp408/components/DSP408Panel/DSP408Panel.tsx @@ -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';