Загрузка данных
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 type { Coords, DropdownPosition, DropdownProps } from './types';
const MENU_GAP = 4;
const VIEWPORT_PADDING = 8;
const positionOrder: Record<DropdownPosition, DropdownPosition[]> = {
bottom: ['bottom', 'top', 'right', 'left'],
top: ['top', 'bottom', 'right', 'left'],
right: ['right', 'left', 'bottom', 'top'],
left: ['left', 'right', 'bottom', 'top'],
};
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 => {
setIsOpenMenu((current) => !current);
};
const handleClose = (): void => {
setIsOpenMenu(false);
setCoords(null);
};
useLayoutEffect(() => {
if (!isOpenMenu) {
return;
}
const calculatePosition = (): 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();
const nextPosition =
positionOrder[position].find((currentPosition) => {
const currentCoords = getPositionCoords(
currentPosition,
triggerRect,
anchorRect,
menuRect,
);
return fitsViewport(currentCoords, menuRect);
}) ?? position;
const nextCoords = getPositionCoords(
nextPosition,
triggerRect,
anchorRect,
menuRect,
);
setCoords(clampToViewport(nextCoords, menuRect));
};
calculatePosition();
window.addEventListener('resize', calculatePosition);
window.addEventListener('scroll', calculatePosition, true);
return () => {
window.removeEventListener('resize', calculatePosition);
window.removeEventListener('scroll', calculatePosition, true);
};
}, [anchorRef, isOpenMenu, position]);
useEffect(() => {
if (!isOpenMenu) {
return;
}
const handlePointerDown = (event: PointerEvent): void => {
const target = event.target as Node;
if (
!rootRef.current?.contains(target) &&
!menuRef.current?.contains(target)
) {
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>
);
}
function getPositionCoords(
position: DropdownPosition,
triggerRect: DOMRect,
anchorRect: DOMRect,
menuRect: DOMRect,
): Coords {
if (position === 'top') {
return {
top: triggerRect.top - menuRect.height - MENU_GAP,
left: triggerRect.right - menuRect.width,
};
}
if (position === 'right') {
return {
top: anchorRect.top,
left: triggerRect.right + MENU_GAP,
};
}
if (position === 'left') {
return {
top: anchorRect.top,
left: triggerRect.left - menuRect.width - MENU_GAP,
};
}
return {
top: triggerRect.bottom + MENU_GAP,
left: triggerRect.right - menuRect.width,
};
}
function fitsViewport(coords: Coords, menuRect: DOMRect): boolean {
return (
coords.left >= VIEWPORT_PADDING &&
coords.top >= VIEWPORT_PADDING &&
coords.left + menuRect.width <= window.innerWidth - VIEWPORT_PADDING &&
coords.top + menuRect.height <= window.innerHeight - VIEWPORT_PADDING
);
}
function clampToViewport(coords: Coords, menuRect: DOMRect): Coords {
return {
left: Math.max(
VIEWPORT_PADDING,
Math.min(
coords.left,
window.innerWidth - menuRect.width - VIEWPORT_PADDING,
),
),
top: Math.max(
VIEWPORT_PADDING,
Math.min(
coords.top,
window.innerHeight - menuRect.height - VIEWPORT_PADDING,
),
),
};
}
@use '../../theme/mixins' as m;
$menu-width: 200px;
.dropdown {
position: relative;
.button {
@include m.buttonBase;
}
}
.menu {
position: fixed;
width: var(--dropdown-width, $menu-width);
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
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;
}
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;
}