Загрузка данных
import { Button } from 'exchange-elements/v2';
import {
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { Portal } from '@src/components/Portal';
import styles from './index.module.scss';
import { getDropdownCoords } from './utils';
import type { Coords, DropdownProps } from './types';
export function Dropdown({
children,
selectedValue,
position = 'bottom',
className = '',
buttonClassName = '',
menuClassName = '',
anchorRef,
renderTrigger,
}: DropdownProps) {
const [isOpenMenu, setIsOpenMenu] = useState(false);
const [coords, setCoords] = useState<Coords | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const handleToggle = (): void => {
if (isOpenMenu) {
handleClose();
return;
}
setIsOpenMenu(true);
};
const handleClose = (): void => {
setIsOpenMenu(false);
setCoords(null);
};
useLayoutEffect(() => {
if (!isOpenMenu) {
return;
}
const updatePosition = (): void => {
const triggerElement = triggerRef.current;
const menuElement = menuRef.current;
if (!triggerElement || !menuElement) {
return;
}
const triggerRect = triggerElement.getBoundingClientRect();
const anchorRect =
anchorRef?.current?.getBoundingClientRect() ??
triggerRect;
const menuRect = menuElement.getBoundingClientRect();
setCoords(
getDropdownCoords({
position,
triggerRect,
anchorRect,
menuRect,
}),
);
};
updatePosition();
const resizeObserver = new ResizeObserver(updatePosition);
if (menuRef.current) {
resizeObserver.observe(menuRef.current);
}
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', updatePosition);
window.removeEventListener(
'scroll',
updatePosition,
true,
);
};
}, [anchorRef, isOpenMenu, position]);
useEffect(() => {
if (!isOpenMenu) {
return;
}
const handlePointerDown = (
event: PointerEvent,
): void => {
const target = event.target as Node;
const isInsideRoot =
rootRef.current?.contains(target);
const isInsideMenu =
menuRef.current?.contains(target);
if (!isInsideRoot && !isInsideMenu) {
handleClose();
}
};
const handleKeyDown = (
event: KeyboardEvent,
): void => {
if (event.key === 'Escape') {
handleClose();
}
};
document.addEventListener(
'pointerdown',
handlePointerDown,
true,
);
document.addEventListener(
'keydown',
handleKeyDown,
);
return () => {
document.removeEventListener(
'pointerdown',
handlePointerDown,
true,
);
document.removeEventListener(
'keydown',
handleKeyDown,
);
};
}, [isOpenMenu]);
return (
<div
ref={rootRef}
className={`${styles.dropdown} ${className}`}
>
<div ref={triggerRef}>
{renderTrigger ? (
renderTrigger({
isOpen: isOpenMenu,
onToggle: handleToggle,
onClose: handleClose,
})
) : (
<Button
size="sm"
onClick={handleToggle}
className={`${styles.button} ${
isOpenMenu ? styles.pressed : ''
} ${buttonClassName}`}
label={selectedValue}
/>
)}
</div>
{isOpenMenu && (
<Portal>
<div
ref={menuRef}
className={`${styles.menu} ${menuClassName}`}
style={{
top: coords?.top,
left: coords?.left,
visibility: coords
? 'visible'
: 'hidden',
}}
>
{children}
</div>
</Portal>
)}
</div>
);
}
import type { Coords, DropdownPosition } from './types';
interface GetDropdownCoordsParams {
position: DropdownPosition;
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,
triggerRect,
anchorRect,
menuRect,
}: GetDropdownCoordsParams): Coords {
const actualPosition = getActualPosition(
position,
triggerRect,
menuRect,
);
const horizontalPosition = getHorizontalPosition(
triggerRect,
menuRect,
);
const verticalPosition = getVerticalPosition(
anchorRect,
menuRect,
);
if (actualPosition === 'top') {
return {
top: clamp(
triggerRect.top - menuRect.height - MENU_GAP,
VIEWPORT_PADDING,
getMaxTop(menuRect),
),
left: horizontalPosition,
};
}
if (actualPosition === 'right') {
return {
top: verticalPosition,
left: clamp(
triggerRect.right + MENU_GAP,
VIEWPORT_PADDING,
getMaxLeft(menuRect),
),
};
}
if (actualPosition === 'left') {
return {
top: verticalPosition,
left: clamp(
triggerRect.left - menuRect.width - MENU_GAP,
VIEWPORT_PADDING,
getMaxLeft(menuRect),
),
};
}
return {
top: clamp(
triggerRect.bottom + MENU_GAP,
VIEWPORT_PADDING,
getMaxTop(menuRect),
),
left: horizontalPosition,
};
}
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,
): number {
const leftAligned = triggerRect.left;
const rightAligned = triggerRect.right - menuRect.width;
const maxLeft = getMaxLeft(menuRect);
if (leftAligned <= maxLeft) {
return clamp(
leftAligned,
VIEWPORT_PADDING,
maxLeft,
);
}
return clamp(
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);
if (topAligned <= maxTop) {
return clamp(
topAligned,
VIEWPORT_PADDING,
maxTop,
);
}
return clamp(
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));
}
import type {
PropsWithChildren,
ReactNode,
RefObject,
} from 'react';
export type DropdownPosition =
| 'bottom'
| 'top'
| 'right'
| 'left';
export interface TriggerRenderProps {
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
}
export interface DropdownProps
extends PropsWithChildren {
selectedValue?: ReactNode;
position?: DropdownPosition;
className?: string;
buttonClassName?: string;
menuClassName?: string;
anchorRef?: RefObject<HTMLDivElement>;
renderTrigger?: (
props: TriggerRenderProps,
) => ReactNode;
}
export interface Coords {
top: number;
left: number;
}
@use '../../theme/mixins' as m;
$menu-width: 200px;
.dropdown {
position: relative;
.button {
@include m.buttonBase;
}
}
.menu {
position: fixed;
box-sizing: border-box;
width: var(--dropdown-width, $menu-width);
max-width: calc(100vw - var(--space-1000));
max-height: calc(100dvh - var(--space-1000));
padding: var(--space-0250) 0;
overflow-y: auto;
border-radius: var(--space-0250);
background-color: var(--neutral-2);
font-family: var(--font-family);
pointer-events: auto;
}