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


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