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


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