FezVrasta/popper.js

View on GitHub
packages/react/src/components/FloatingArrow.tsx

Summary

Maintainability
C
1 day
Test Coverage
import {getComputedStyle} from '@floating-ui/utils/dom';
import * as React from 'react';

import {useId} from '../hooks/useId';
import type {Alignment, FloatingContext, Side} from '../types';
import {warn} from '../utils/log';
import useModernLayoutEffect from 'use-isomorphic-layout-effect';

export interface FloatingArrowProps extends React.ComponentPropsWithRef<'svg'> {
  // Omit the original `refs` property from the context to avoid issues with
  // generics: https://github.com/floating-ui/floating-ui/issues/2483
  /**
   * The floating context.
   */
  context: Omit<FloatingContext, 'refs'> & {refs: any};
  /**
   * Width of the arrow.
   * @default 14
   */
  width?: number;
  /**
   * Height of the arrow.
   * @default 7
   */
  height?: number;
  /**
   * The corner radius (rounding) of the arrow tip.
   * @default 0 (sharp)
   */
  tipRadius?: number;
  /**
   * Forces a static offset over dynamic positioning under a certain condition.
   * If the shift() middleware causes the popover to shift, this value will be
   * ignored.
   */
  staticOffset?: string | number | null;
  /**
   * Custom path string.
   */
  d?: string;
  /**
   * Stroke (border) color of the arrow.
   */
  stroke?: string;
  /**
   * Stroke (border) width of the arrow.
   */
  strokeWidth?: number;
}

/**
 * Renders a pointing arrow triangle.
 * @see https://floating-ui.com/docs/FloatingArrow
 */
export const FloatingArrow = React.forwardRef(function FloatingArrow(
  props: FloatingArrowProps,
  ref: React.ForwardedRef<SVGSVGElement>,
): React.JSX.Element | null {
  const {
    context: {
      placement,
      elements: {floating},
      middlewareData: {arrow, shift},
    },
    width = 14,
    height = 7,
    tipRadius = 0,
    strokeWidth = 0,
    staticOffset,
    stroke,
    d,
    style: {transform, ...restStyle} = {},
    ...rest
  } = props;

  if (__DEV__) {
    if (!ref) {
      warn('The `ref` prop is required for `FloatingArrow`.');
    }
  }

  const clipPathId = useId();
  const [isRTL, setIsRTL] = React.useState(false);

  // https://github.com/floating-ui/floating-ui/issues/2932
  useModernLayoutEffect(() => {
    if (!floating) return;
    const isRTL = getComputedStyle(floating).direction === 'rtl';
    if (isRTL) {
      setIsRTL(true);
    }
  }, [floating]);

  if (!floating) {
    return null;
  }

  const [side, alignment] = placement.split('-') as [Side, Alignment];
  const isVerticalSide = side === 'top' || side === 'bottom';

  let computedStaticOffset = staticOffset;
  if ((isVerticalSide && shift?.x) || (!isVerticalSide && shift?.y)) {
    computedStaticOffset = null;
  }

  // Strokes must be double the border width, this ensures the stroke's width
  // works as you'd expect.
  const computedStrokeWidth = strokeWidth * 2;
  const halfStrokeWidth = computedStrokeWidth / 2;

  const svgX = (width / 2) * (tipRadius / -8 + 1);
  const svgY = ((height / 2) * tipRadius) / 4;

  const isCustomShape = !!d;

  const yOffsetProp =
    computedStaticOffset && alignment === 'end' ? 'bottom' : 'top';
  let xOffsetProp =
    computedStaticOffset && alignment === 'end' ? 'right' : 'left';
  if (computedStaticOffset && isRTL) {
    xOffsetProp = alignment === 'end' ? 'left' : 'right';
  }

  const arrowX = arrow?.x != null ? computedStaticOffset || arrow.x : '';
  const arrowY = arrow?.y != null ? computedStaticOffset || arrow.y : '';

  const dValue =
    d ||
    'M0,0' +
      ` H${width}` +
      ` L${width - svgX},${height - svgY}` +
      ` Q${width / 2},${height} ${svgX},${height - svgY}` +
      ' Z';

  const rotation = {
    top: isCustomShape ? 'rotate(180deg)' : '',
    left: isCustomShape ? 'rotate(90deg)' : 'rotate(-90deg)',
    bottom: isCustomShape ? '' : 'rotate(180deg)',
    right: isCustomShape ? 'rotate(-90deg)' : 'rotate(90deg)',
  }[side];

  return (
    <svg
      {...rest}
      aria-hidden
      ref={ref}
      width={isCustomShape ? width : width + computedStrokeWidth}
      height={width}
      viewBox={`0 0 ${width} ${height > width ? height : width}`}
      style={{
        position: 'absolute',
        pointerEvents: 'none',
        [xOffsetProp]: arrowX,
        [yOffsetProp]: arrowY,
        [side]:
          isVerticalSide || isCustomShape
            ? '100%'
            : `calc(100% - ${computedStrokeWidth / 2}px)`,
        transform: [rotation, transform].filter((t) => !!t).join(' '),
        ...restStyle,
      }}
    >
      {computedStrokeWidth > 0 && (
        <path
          clipPath={`url(#${clipPathId})`}
          fill="none"
          stroke={stroke}
          // Account for the stroke on the fill path rendered below.
          strokeWidth={computedStrokeWidth + (d ? 0 : 1)}
          d={dValue}
        />
      )}
      {/* In Firefox, for left/right placements there's a ~0.5px gap where the
      border can show through. Adding a stroke on the fill removes it. */}
      <path
        stroke={computedStrokeWidth && !d ? rest.fill : 'none'}
        d={dValue}
      />
      {/* Assumes the border-width of the floating element matches the 
      stroke. */}
      <clipPath id={clipPathId}>
        <rect
          x={-halfStrokeWidth}
          y={halfStrokeWidth * (isCustomShape ? -1 : 1)}
          width={width + computedStrokeWidth}
          height={width}
        />
      </clipPath>
    </svg>
  );
});