mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-06 20:07:40 +00:00
feat(dropdown): improve positioning logic and add popup positioning utility
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user