Загрузка данных
import type { PropsWithChildren, ReactNode, RefObject } from 'react';
export type DropdownPosition = 'bottom' | 'top' | 'right' | 'left';
export type DropdownHorizontalAlign = 'auto' | 'left' | 'right';
export interface TriggerRenderProps {
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
}
export interface DropdownProps extends PropsWithChildren {
selectedValue?: ReactNode;
position?: DropdownPosition;
horizontalAlign?: DropdownHorizontalAlign;
className?: string;
buttonClassName?: string;
menuClassName?: string;
anchorRef?: RefObject<HTMLDivElement>;
renderTrigger?: (props: TriggerRenderProps) => ReactNode;
}
export interface Coords {
top: number;
left: number;
}
import type {
Coords,
DropdownHorizontalAlign,
DropdownPosition,
} from './types';
interface GetDropdownCoordsParams {
position: DropdownPosition;
horizontalAlign: DropdownHorizontalAlign;
triggerRect: DOMRect;
anchorRect: DOMRect;
menuRect: DOMRect;
}
const MENU_GAP = 4;
const VIEWPORT_PADDING = 8;
const oppositePosition: Record<DropdownPosition, DropdownPosition> = {
bottom: 'top',
top: 'bottom',
right: 'left',
left: 'right',
};
export function getDropdownCoords({
position,
horizontalAlign,
triggerRect,
anchorRect,
menuRect,
}: GetDropdownCoordsParams): Coords {
const actualPosition = getActualPosition(position, triggerRect, menuRect);
if (actualPosition === 'top') {
return {
top: clamp(
triggerRect.top - menuRect.height - MENU_GAP,
VIEWPORT_PADDING,
getMaxTop(menuRect),
),
left: getHorizontalPosition(
triggerRect,
menuRect,
horizontalAlign,
),
};
}
if (actualPosition === 'right') {
return {
top: getVerticalPosition(anchorRect, menuRect),
left: clamp(
triggerRect.right + MENU_GAP,
VIEWPORT_PADDING,
getMaxLeft(menuRect),
),
};
}
if (actualPosition === 'left') {
return {
top: getVerticalPosition(anchorRect, menuRect),
left: clamp(
triggerRect.left - menuRect.width - MENU_GAP,
VIEWPORT_PADDING,
getMaxLeft(menuRect),
),
};
}
return {
top: clamp(
triggerRect.bottom + MENU_GAP,
VIEWPORT_PADDING,
getMaxTop(menuRect),
),
left: getHorizontalPosition(
triggerRect,
menuRect,
horizontalAlign,
),
};
}
function getActualPosition(
position: DropdownPosition,
triggerRect: DOMRect,
menuRect: DOMRect,
): DropdownPosition {
const fallbackPosition = oppositePosition[position];
const menuSize = isVerticalPosition(position)
? menuRect.height
: menuRect.width;
const availableSpace = getAvailableSpace(position, triggerRect);
if (availableSpace >= menuSize) {
return position;
}
const fallbackAvailableSpace = getAvailableSpace(
fallbackPosition,
triggerRect,
);
if (fallbackAvailableSpace >= menuSize) {
return fallbackPosition;
}
return availableSpace >= fallbackAvailableSpace
? position
: fallbackPosition;
}
function getAvailableSpace(
position: DropdownPosition,
triggerRect: DOMRect,
): number {
if (position === 'top') {
return triggerRect.top - MENU_GAP - VIEWPORT_PADDING;
}
if (position === 'right') {
return (
window.innerWidth -
triggerRect.right -
MENU_GAP -
VIEWPORT_PADDING
);
}
if (position === 'left') {
return triggerRect.left - MENU_GAP - VIEWPORT_PADDING;
}
return (
window.innerHeight -
triggerRect.bottom -
MENU_GAP -
VIEWPORT_PADDING
);
}
function getHorizontalPosition(
triggerRect: DOMRect,
menuRect: DOMRect,
horizontalAlign: DropdownHorizontalAlign,
): number {
const leftAligned = triggerRect.left;
const rightAligned = triggerRect.right - menuRect.width;
const maxLeft = getMaxLeft(menuRect);
if (horizontalAlign === 'left') {
return clamp(leftAligned, VIEWPORT_PADDING, maxLeft);
}
if (horizontalAlign === 'right') {
return clamp(rightAligned, VIEWPORT_PADDING, maxLeft);
}
const hasSpaceOnRight =
leftAligned + menuRect.width <=
window.innerWidth - VIEWPORT_PADDING;
return clamp(
hasSpaceOnRight ? leftAligned : rightAligned,
VIEWPORT_PADDING,
maxLeft,
);
}
function getVerticalPosition(
anchorRect: DOMRect,
menuRect: DOMRect,
): number {
const topAligned = anchorRect.top;
const bottomAligned = anchorRect.bottom - menuRect.height;
const maxTop = getMaxTop(menuRect);
const hasSpaceBelow =
topAligned + menuRect.height <=
window.innerHeight - VIEWPORT_PADDING;
return clamp(
hasSpaceBelow ? topAligned : bottomAligned,
VIEWPORT_PADDING,
maxTop,
);
}
function getMaxLeft(menuRect: DOMRect): number {
return Math.max(
VIEWPORT_PADDING,
window.innerWidth - menuRect.width - VIEWPORT_PADDING,
);
}
function getMaxTop(menuRect: DOMRect): number {
return Math.max(
VIEWPORT_PADDING,
window.innerHeight - menuRect.height - VIEWPORT_PADDING,
);
}
function isVerticalPosition(position: DropdownPosition): boolean {
return position === 'top' || position === 'bottom';
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max));
}