Загрузка данных


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;
}