diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx index fcc34b3..97cb768 100644 --- a/frontend/src/components/ui/dropdown-menu.tsx +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import {createPortal} from 'react-dom'; import {cn} from '@/lib/utils'; +import {placeUnder} from '@/lib/popup-position'; interface DropdownMenuContextValue { open: boolean; @@ -62,36 +63,30 @@ const DropdownMenuContent = React.forwardRef { const { open, setOpen, triggerRef } = useDropdownMenu(); const contentRef = React.useRef(null); - const [position, setPosition] = React.useState({ top: 0, left: 0 }); + const [position, setPosition] = React.useState({}); + + // Positioned in viewport coordinates against the trigger, since the menu is + // portalled to the body and rendered fixed. + React.useLayoutEffect(() => { + if (!open) return; - // Calculate position based on trigger element - React.useEffect(() => { const updatePosition = () => { - if (open && triggerRef.current) { - const rect = triggerRef.current.getBoundingClientRect(); - const scrollY = window.scrollY || document.documentElement.scrollTop; - const scrollX = window.scrollX || document.documentElement.scrollLeft; + if (!triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); - let left = rect.left + scrollX; - const top = rect.bottom + scrollY + 8; // 8px gap (mt-2) - - // Adjust horizontal alignment - if (align === 'end') { - left = rect.right + scrollX - 224; // 224px = w-56 - } else if (align === 'center') { - left = rect.left + scrollX + (rect.width / 2) - 112; // 112px = half of w-56 - } - - setPosition({ top, left }); + let left = rect.left; + if (align === 'end') { + left = rect.right - 224; // 224px = w-56 + } else if (align === 'center') { + left = rect.left + rect.width / 2 - 112; // 112px = half of w-56 } + + setPosition({ left, ...placeUnder(rect, window.innerHeight, 8) }); }; updatePosition(); - - if (open) { - window.addEventListener('scroll', updatePosition, true); - window.addEventListener('resize', updatePosition); - } + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); return () => { window.removeEventListener('scroll', updatePosition, true); @@ -126,11 +121,10 @@ const DropdownMenuContent = React.forwardRef { }; const Select = React.forwardRef( - ({ className, children, value, onChange, disabled, placeholder = 'Select an option...', ...props }, _ref) => { + ({ className, children, value, onChange, disabled, placeholder = 'Select an option...', ...props }, ref) => { const [open, setOpen] = React.useState(false); const [internalValue, setInternalValue] = React.useState(value); const containerRef = React.useRef(null); const buttonRef = React.useRef(null); + const popupRef = React.useRef(null); + const [popupStyle, setPopupStyle] = React.useState({}); + + // The forwarded ref points at the trigger, as it does on DropdownMenuTrigger. + React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement); const displayValue = React.useMemo(() => { const currentValue = value ?? internalValue; @@ -62,11 +69,40 @@ const Select = React.forwardRef( setInternalValue(value); }, [value]); + // The popup is portalled to the body so an ancestor with overflow-hidden + // (a dialog card, a scroll container) cannot clip it. + React.useLayoutEffect(() => { + if (!open) return; + + const updatePosition = () => { + const trigger = buttonRef.current; + if (!trigger) return; + const rect = trigger.getBoundingClientRect(); + + setPopupStyle({ + position: 'fixed', + left: rect.left, + width: rect.width, + backgroundColor: 'var(--popover)', + ...placeUnder(rect, window.innerHeight), + }); + }; + + updatePosition(); + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [open]); + React.useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setOpen(false); - } + const target = event.target as Node; + if (containerRef.current?.contains(target) || popupRef.current?.contains(target)) return; + setOpen(false); }; if (open) { @@ -106,13 +142,15 @@ const Select = React.forwardRef( - {open && ( + {open && createPortal(
{children} -
+ , + document.body, )} diff --git a/frontend/src/lib/popup-position.ts b/frontend/src/lib/popup-position.ts new file mode 100644 index 0000000..7116e52 --- /dev/null +++ b/frontend/src/lib/popup-position.ts @@ -0,0 +1,31 @@ +export interface Placement { + /** Distance from the viewport top, when the popup opens downwards. */ + top?: number; + /** Distance from the viewport bottom, when the popup flips above its trigger. */ + bottom?: number; + maxHeight: number; +} + +/** + * Places a floating layer against its trigger in viewport coordinates, for use + * with `position: fixed` so no ancestor's overflow can clip it. Flips above the + * trigger when the room below is too tight to be usable. + */ +export function placeUnder( + rect: { top: number; bottom: number }, + viewportHeight: number, + gap = 4, + maxHeight = 240, +): Placement { + const spaceBelow = viewportHeight - rect.bottom - gap * 2; + const spaceAbove = rect.top - gap * 2; + const flip = spaceBelow < Math.min(maxHeight, 160) && spaceAbove > spaceBelow; + + // Floor the height so a trigger at the very edge still shows a scrollable + // popup rather than collapsing to nothing. + const fit = (space: number) => Math.max(120, Math.min(maxHeight, space)); + + return flip + ? { bottom: viewportHeight - rect.top + gap, maxHeight: fit(spaceAbove) } + : { top: rect.bottom + gap, maxHeight: fit(spaceBelow) }; +}