FezVrasta/popper.js

View on GitHub
packages/react/src/inner.ts

Summary

Maintainability
C
1 day
Test Coverage
import {getUserAgent} from '@floating-ui/react/utils';
import {detectOverflow, offset} from '@floating-ui/react-dom';
import * as React from 'react';
import {flushSync} from 'react-dom';

import {useEffectEvent} from './hooks/utils/useEffectEvent';
import type {
  DetectOverflowOptions,
  ElementProps,
  FloatingContext,
  Middleware,
  MiddlewareState,
  SideObject,
} from './types';
import {warn} from './utils/log';

function getArgsWithCustomFloatingHeight(
  state: MiddlewareState,
  height: number,
) {
  return {
    ...state,
    rects: {
      ...state.rects,
      floating: {
        ...state.rects.floating,
        height,
      },
    },
  };
}

export interface InnerProps {
  /**
   * A ref which contains an array of HTML elements.
   * @default empty list
   */
  listRef: React.MutableRefObject<Array<HTMLElement | null>>;
  /**
   * The index of the active (focused or highlighted) item in the list.
   * @default 0
   */
  index: number;
  /**
   * Callback invoked when the fallback state changes.
   */
  onFallbackChange?: null | ((fallback: boolean) => void);
  /**
   * The offset to apply to the floating element.
   * @default 0
   */
  offset?: number;
  /**
   * A ref which contains the overflow of the floating element.
   */
  overflowRef?: React.MutableRefObject<SideObject | null>;
  /**
   * An optional ref containing an HTMLElement. This may be used as the
   * scrolling container instead of the floating element — for instance,
   * to position inner elements as direct children without being interfered by
   * scrolling layout.
   */
  scrollRef?: React.MutableRefObject<HTMLElement | null>;
  /**
   * The minimum number of items that should be visible in the list.
   * @default 4
   */
  minItemsVisible?: number;
  /**
   * The threshold for the reference element's overflow in pixels.
   * @default 0
   */
  referenceOverflowThreshold?: number;
}

/**
 * Positions the floating element such that an inner element inside
 * of it is anchored to the reference element.
 * @see https://floating-ui.com/docs/inner
 */
export const inner = (
  props: InnerProps & Partial<DetectOverflowOptions>,
): Middleware => ({
  name: 'inner',
  options: props,
  async fn(state) {
    const {
      listRef,
      overflowRef,
      onFallbackChange,
      offset: innerOffset = 0,
      index = 0,
      minItemsVisible = 4,
      referenceOverflowThreshold = 0,
      scrollRef,
      ...detectOverflowOptions
    } = props;

    const {
      rects,
      elements: {floating},
    } = state;

    const item = listRef.current[index];

    if (__DEV__) {
      if (!state.placement.startsWith('bottom')) {
        warn(
          '`placement` side must be "bottom" when using the `inner`',
          'middleware.',
        );
      }
    }

    if (!item) {
      return {};
    }

    const nextArgs = {
      ...state,
      ...(await offset(
        -item.offsetTop -
          floating.clientTop -
          rects.reference.height / 2 -
          item.offsetHeight / 2 -
          innerOffset,
      ).fn(state)),
    };

    const el = scrollRef?.current || floating;

    const overflow = await detectOverflow(
      getArgsWithCustomFloatingHeight(nextArgs, el.scrollHeight),
      detectOverflowOptions,
    );
    const refOverflow = await detectOverflow(nextArgs, {
      ...detectOverflowOptions,
      elementContext: 'reference',
    });

    const diffY = Math.max(0, overflow.top);
    const nextY = nextArgs.y + diffY;

    const maxHeight = Math.max(
      0,
      el.scrollHeight - diffY - Math.max(0, overflow.bottom),
    );

    el.style.maxHeight = `${maxHeight}px`;
    el.scrollTop = diffY;

    // There is not enough space, fallback to standard anchored positioning
    if (onFallbackChange) {
      if (
        el.offsetHeight <
          item.offsetHeight *
            Math.min(minItemsVisible, listRef.current.length - 1) -
            1 ||
        refOverflow.top >= -referenceOverflowThreshold ||
        refOverflow.bottom >= -referenceOverflowThreshold
      ) {
        flushSync(() => onFallbackChange(true));
      } else {
        flushSync(() => onFallbackChange(false));
      }
    }

    if (overflowRef) {
      overflowRef.current = await detectOverflow(
        getArgsWithCustomFloatingHeight(
          {...nextArgs, y: nextY},
          el.offsetHeight,
        ),
        detectOverflowOptions,
      );
    }

    return {
      y: nextY,
    };
  },
});

export interface UseInnerOffsetProps {
  /**
   * Whether the Hook is enabled, including all internal Effects and event
   * handlers.
   * @default true
   */
  enabled?: boolean;
  /**
   * A ref which contains the overflow of the floating element.
   */
  overflowRef: React.MutableRefObject<SideObject | null>;
  /**
   * An optional ref containing an HTMLElement. This may be used as the
   * scrolling container instead of the floating element — for instance,
   * to position inner elements as direct children without being interfered by
   * scrolling layout.
   */
  scrollRef?: React.MutableRefObject<HTMLElement | null>;
  /**
   * Callback invoked when the offset changes.
   */
  onChange: (offset: number | ((offset: number) => number)) => void;
}

/**
 * Changes the `inner` middleware's `offset` upon a `wheel` event to
 * expand the floating element's height, revealing more list items.
 * @see https://floating-ui.com/docs/inner
 */
export function useInnerOffset(
  context: FloatingContext,
  props: UseInnerOffsetProps,
): ElementProps {
  const {open, elements} = context;
  const {
    enabled = true,
    overflowRef,
    scrollRef,
    onChange: unstable_onChange,
  } = props;

  const onChange = useEffectEvent(unstable_onChange);
  const controlledScrollingRef = React.useRef(false);
  const prevScrollTopRef = React.useRef<number | null>(null);
  const initialOverflowRef = React.useRef<SideObject | null>(null);

  React.useEffect(() => {
    if (!enabled) {
      return;
    }

    function onWheel(e: WheelEvent) {
      if (e.ctrlKey || !el || overflowRef.current == null) {
        return;
      }

      const dY = e.deltaY;
      const isAtTop = overflowRef.current.top >= -0.5;
      const isAtBottom = overflowRef.current.bottom >= -0.5;
      const remainingScroll = el.scrollHeight - el.clientHeight;
      const sign = dY < 0 ? -1 : 1;
      const method = dY < 0 ? 'max' : 'min';

      if (el.scrollHeight <= el.clientHeight) {
        return;
      }

      if ((!isAtTop && dY > 0) || (!isAtBottom && dY < 0)) {
        e.preventDefault();
        flushSync(() => {
          onChange((d) => d + Math[method](dY, remainingScroll * sign));
        });
      } else if (/firefox/i.test(getUserAgent())) {
        // Needed to propagate scrolling during momentum scrolling phase once
        // it gets limited by the boundary. UX improvement, not critical.
        el.scrollTop += dY;
      }
    }

    const el = scrollRef?.current || elements.floating;

    if (open && el) {
      el.addEventListener('wheel', onWheel);

      // Wait for the position to be ready.
      requestAnimationFrame(() => {
        prevScrollTopRef.current = el.scrollTop;

        if (overflowRef.current != null) {
          initialOverflowRef.current = {...overflowRef.current};
        }
      });

      return () => {
        prevScrollTopRef.current = null;
        initialOverflowRef.current = null;
        el.removeEventListener('wheel', onWheel);
      };
    }
  }, [enabled, open, elements.floating, overflowRef, scrollRef, onChange]);

  return React.useMemo(() => {
    if (!enabled) {
      return {};
    }

    return {
      floating: {
        onKeyDown() {
          controlledScrollingRef.current = true;
        },
        onWheel() {
          controlledScrollingRef.current = false;
        },
        onPointerMove() {
          controlledScrollingRef.current = false;
        },
        onScroll() {
          const el = scrollRef?.current || elements.floating;

          if (!overflowRef.current || !el || !controlledScrollingRef.current) {
            return;
          }

          if (prevScrollTopRef.current !== null) {
            const scrollDiff = el.scrollTop - prevScrollTopRef.current;

            if (
              (overflowRef.current.bottom < -0.5 && scrollDiff < -1) ||
              (overflowRef.current.top < -0.5 && scrollDiff > 1)
            ) {
              flushSync(() => onChange((d) => d + scrollDiff));
            }
          }

          // [Firefox] Wait for the height change to have been applied.
          requestAnimationFrame(() => {
            prevScrollTopRef.current = el.scrollTop;
          });
        },
      },
    };
  }, [enabled, overflowRef, elements.floating, scrollRef, onChange]);
}