FezVrasta/popper.js

View on GitHub
website/lib/components/Home/DropdownMenu.js

Summary

Maintainability
A
3 hrs
Test Coverage
import {
  autoUpdate,
  flip,
  FloatingFocusManager,
  FloatingNode,
  FloatingPortal,
  FloatingTree,
  offset,
  safePolygon,
  shift,
  useClick,
  useDismiss,
  useFloating,
  useFloatingNodeId,
  useFloatingParentNodeId,
  useFloatingTree,
  useHover,
  useInteractions,
  useListNavigation,
  useMergeRefs,
  useRole,
  useTransitionStyles,
  useTypeahead,
} from '@floating-ui/react';
import classNames from 'classnames';
import {
  Children,
  cloneElement,
  forwardRef,
  isValidElement,
  useEffect,
  useRef,
  useState,
} from 'react';
import {ChevronRight} from 'react-feather';

export const MenuComponent = forwardRef(function Menu(
  {children, label, ...props},
  forwardedRef,
) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(null);
  const [allowHover, setAllowHover] = useState(false);
  const [hasFocusInside, setHasFocusInside] = useState(false);

  const listItemsRef = useRef([]);
  const listContentRef = useRef([]);

  useEffect(() => {
    const strings = [];
    Children.forEach(children, (child) => {
      if (isValidElement(child)) {
        strings.push(
          child.props.label && !child.props.disabled
            ? child.props.label
            : null,
        );
      }
    });
    listContentRef.current = strings;
  });

  const tree = useFloatingTree();
  const nodeId = useFloatingNodeId();
  const parentId = useFloatingParentNodeId();
  const isNested = parentId != null;

  const {refs, floatingStyles, context} = useFloating({
    nodeId,
    transform: false,
    open: isOpen,
    onOpenChange: setIsOpen,
    placement: isNested ? 'right-start' : 'bottom-start',
    middleware: [
      offset({
        mainAxis: isNested ? 0 : 4,
        alignmentAxis: isNested ? -4 : 0,
      }),
      flip(),
      shift(),
    ],
    whileElementsMounted: autoUpdate,
  });

  const hover = useHover(context, {
    enabled: isNested && allowHover,
    delay: {open: 75},
    handleClose: safePolygon({
      blockPointerEvents: true,
    }),
  });
  const click = useClick(context, {
    event: 'mousedown',
    toggle: !isNested || !allowHover,
    ignoreMouse: isNested,
  });
  const role = useRole(context, {role: 'menu'});
  const dismiss = useDismiss(context, {bubbles: true});
  const listNavigation = useListNavigation(context, {
    listRef: listItemsRef,
    activeIndex,
    nested: isNested,
    onNavigate: setActiveIndex,
  });
  const typeahead = useTypeahead(context, {
    enabled: isOpen,
    listRef: listContentRef,
    onMatch: isOpen ? setActiveIndex : undefined,
    activeIndex,
  });

  const {getReferenceProps, getFloatingProps, getItemProps} =
    useInteractions([
      hover,
      click,
      role,
      dismiss,
      listNavigation,
      typeahead,
    ]);

  // Event emitter allows you to communicate across tree components.
  // This effect closes all menus when an item gets clicked anywhere
  // in the tree.
  useEffect(() => {
    if (!tree) return;

    function handleTreeClick() {
      setIsOpen(false);
    }

    function onSubMenuOpen(event) {
      if (
        event.nodeId !== nodeId &&
        event.parentId === parentId
      ) {
        setIsOpen(false);
      }
    }

    tree.events.on('click', handleTreeClick);
    tree.events.on('menuopen', onSubMenuOpen);

    return () => {
      tree.events.off('click', handleTreeClick);
      tree.events.off('menuopen', onSubMenuOpen);
    };
  }, [tree, nodeId, parentId]);

  useEffect(() => {
    if (isOpen && tree) {
      tree.events.emit('menuopen', {parentId, nodeId});
    }
  }, [tree, isOpen, nodeId, parentId]);

  // Determine if "hover" logic can run based on the modality of input. This
  // prevents unwanted focus synchronization as menus open and close with
  // keyboard navigation and the cursor is resting on the menu.
  useEffect(() => {
    function onPointerMove({pointerType}) {
      if (pointerType !== 'touch') {
        setAllowHover(true);
      }
    }

    function onKeyDown() {
      setAllowHover(false);
    }

    window.addEventListener('pointermove', onPointerMove, {
      once: true,
      capture: true,
    });
    window.addEventListener('keydown', onKeyDown, true);
    return () => {
      window.removeEventListener('pointermove', onPointerMove, {
        capture: true,
      });
      window.removeEventListener('keydown', onKeyDown, true);
    };
  }, [allowHover]);

  const {isMounted, styles} = useTransitionStyles(context, {
    duration: {open: 100},
  });

  const referenceRef = useMergeRefs([
    refs.setReference,
    forwardedRef,
  ]);
  const referenceProps = getReferenceProps({
    ...props,
    onFocus(event) {
      props.onFocus?.(event);
      setHasFocusInside(false);
    },
    onClick(event) {
      event.stopPropagation();
    },
    ...(isNested && {
      // Indicates this is a nested <Menu /> acting as a <MenuItem />.
      role: 'menuitem',
    }),
  });

  return (
    <FloatingNode id={nodeId}>
      <button
        ref={referenceRef}
        data-open={isOpen ? '' : undefined}
        {...referenceProps}
        className={classNames(
          'flex cursor-default items-center justify-between gap-4 rounded py-2 px-3 text-left',
          {
            'transition-colors hover:bg-gray-100/50 data-[open]:bg-gray-100/50 dark:hover:bg-gray-600 dark:data-[open]:bg-gray-600':
              !isNested,
            'outline-none focus:bg-blue-500 focus:text-white':
              isNested,
            'bg-blue-500 text-white':
              isOpen && isNested && !hasFocusInside,
            'rounded bg-gray-500/20 dark:bg-gray-700':
              isNested && isOpen && hasFocusInside,
          },
        )}
      >
        {label}{' '}
        {isNested && (
          <span aria-hidden className="ml-4">
            <ChevronRight size={16} />
          </span>
        )}
      </button>
      {isMounted && (
        <FloatingPortal>
          <FloatingFocusManager
            context={context}
            // Prevent outside content interference.
            modal={false}
            // Only initially focus the root floating menu.
            initialFocus={isNested ? -1 : 0}
            // Only return focus to the root menu's reference when menus close.
            returnFocus={!isNested}
          >
            <div
              ref={refs.setFloating}
              className="flex flex-col rounded border border-slate-900/10 bg-white/80 bg-clip-padding p-1 shadow-lg outline-none backdrop-blur-lg dark:bg-gray-600/80"
              style={{
                ...floatingStyles,
                width: 'max-content',
                ...styles,
              }}
              {...getFloatingProps()}
            >
              {Children.map(
                children,
                (child, index) =>
                  isValidElement(child) &&
                  cloneElement(
                    child,
                    getItemProps({
                      tabIndex: activeIndex === index ? 0 : -1,
                      ref(node) {
                        listItemsRef.current[index] = node;
                      },
                      onClick(event) {
                        child.props.onClick?.(event);
                        tree?.events.emit('click');
                      },
                      onFocus(event) {
                        child.props.onFocus?.(event);
                        setHasFocusInside(true);
                      },
                      // Allow focus synchronization if the cursor did not move.
                      onMouseEnter() {
                        if (allowHover && isOpen) {
                          setActiveIndex(index);
                        }
                      },
                    }),
                  ),
              )}
            </div>
          </FloatingFocusManager>
        </FloatingPortal>
      )}
    </FloatingNode>
  );
});

export const Menu = forwardRef(function MenuWrapper(props, ref) {
  const parentId = useFloatingParentNodeId();

  if (parentId === null) {
    return (
      <FloatingTree>
        <MenuComponent {...props} ref={ref} />
      </FloatingTree>
    );
  }

  return <MenuComponent {...props} ref={ref} />;
});

export const MenuItem = forwardRef(function MenuItem(
  {label, disabled, ...props},
  ref,
) {
  return (
    <button
      type="button"
      {...props}
      className={classNames(
        'flex cursor-default rounded py-2 px-3 text-left outline-none focus:bg-blue-500 focus:text-white',
        {
          'opacity-40': disabled,
        },
      )}
      ref={ref}
      role="menuitem"
      disabled={disabled}
    >
      {label}
    </button>
  );
});