feat(dropdown): improve positioning logic and add popup positioning utility

This commit is contained in:
Noooste
2026-07-25 12:29:31 +02:00
parent 8f6cb2b1da
commit 985b7b30ce
3 changed files with 97 additions and 34 deletions
+20 -26
View File
@@ -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<HTMLDivElement, DropdownMenuContent
({ className, children, align = 'start', ...props }) => {
const { open, setOpen, triggerRef } = useDropdownMenu();
const contentRef = React.useRef<HTMLDivElement>(null);
const [position, setPosition] = React.useState({ top: 0, left: 0 });
const [position, setPosition] = React.useState<React.CSSProperties>({});
// 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<HTMLDivElement, DropdownMenuContent
style={{
backgroundColor: 'var(--popover)',
position: 'fixed',
top: `${position.top}px`,
left: `${position.left}px`,
...position,
}}
className={cn(
'z-50 w-56 origin-top-right rounded-md text-popover-foreground shadow-lg ring-1 ring-border border border-border focus:outline-none',
'z-50 w-56 origin-top-right overflow-auto rounded-md text-popover-foreground shadow-lg ring-1 ring-border border border-border focus:outline-none',
className
)}
{...props}
+46 -8
View File
@@ -1,5 +1,7 @@
import * as React from 'react';
import {createPortal} from 'react-dom';
import {cn} from '@/lib/utils';
import {placeUnder} from '@/lib/popup-position';
import {ChevronDown, Check} from 'lucide-react';
export interface SelectOption {
@@ -32,11 +34,16 @@ const useSelectContext = () => {
};
const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
({ 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<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const popupRef = React.useRef<HTMLDivElement>(null);
const [popupStyle, setPopupStyle] = React.useState<React.CSSProperties>({});
// 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<HTMLButtonElement, SelectProps>(
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<HTMLButtonElement, SelectProps>(
<ChevronDown className={cn('h-4 w-4 opacity-50 transition-transform', open && 'transform rotate-180')} />
</button>
{open && (
{open && createPortal(
<div
className="absolute z-50 w-full mt-1 text-popover-foreground rounded-md border border-border shadow-lg max-h-60 overflow-auto"
style={{ backgroundColor: 'var(--popover)' }}
ref={popupRef}
className="z-50 text-popover-foreground rounded-md border border-border shadow-lg overflow-auto"
style={popupStyle}
>
{children}
</div>
</div>,
document.body,
)}
</div>
</SelectContext.Provider>
+31
View File
@@ -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) };
}