azmenak / material-ui-nested-menu-item

Drop-in replacement for MUI's MenuItem with infinitely nested menus, and open on hover
73 stars 44 forks source link

@material-ui to @mui #31

Open prakash2091 opened 2 years ago

prakash2091 commented 2 years ago

I have upgraded my project from @material-ui to @mui and i see below error when i use material-ui-nested-menu-item now.

error: "./node_modules/material-ui-nested-menu-item/dist-web/index.js Module not found: Can't resolve '@material-ui/core/Menu' in '/app/node_modules/material-ui-nested-menu-item/dist-web'"

later i found that the below dependencies incompatible with latest material ui library.

"@material-ui/core": "^4.9.0", "@material-ui/icons": "^4.5.1",

kindly advice...

RuellePaul commented 2 years ago

This library is not compatible with MUI v5, for now.

If you're working with Typescript, I adapted the code for V5 compatibility, while waiting for the maintainer to do the necessary :

In NestedMenuItem.tsx ;

import React, {useImperativeHandle, useRef, useState} from 'react';
import makeStyles from '@mui/styles/makeStyles';
import {Menu, MenuItem, MenuItemProps, MenuProps} from '@mui/material';
import {ArrowRight} from '@mui/icons-material';
import clsx from 'clsx';

export interface NestedMenuItemProps extends Omit<MenuItemProps, 'button'> {
    /**
     * Open state of parent `<Menu />`, used to close decendent menus when the
     * root menu is closed.
     */
    parentMenuOpen: boolean;
    /**
     * Component for the container element.
     * @default 'div'
     */
    component?: React.ElementType;
    /**
     * Effectively becomes the `children` prop passed to the `<MenuItem/>`
     * element.
     */
    label?: React.ReactNode;
    /**
     * @default <ArrowRight />
     */
    rightIcon?: React.ReactNode;
    /**
     * Props passed to container element.
     */
    ContainerProps?: React.HTMLAttributes<HTMLElement> & React.RefAttributes<HTMLElement | null>;
    /**
     * Props passed to sub `<Menu/>` element
     */
    MenuProps?: Omit<MenuProps, 'children'>;
    /**
     * @see https://material-ui.com/api/list-item/
     */
    button?: true | undefined;
}

const TRANSPARENT = 'rgba(0,0,0,0)';
const useMenuItemStyles = makeStyles(theme => ({
    root: (props: any) => ({
        backgroundColor: props.open ? theme.palette.action.hover : TRANSPARENT
    })
}));

/**
 * Use as a drop-in replacement for `<MenuItem>` when you need to add cascading
 * menu elements as children to this component.
 */
const NestedMenuItem = React.forwardRef<HTMLLIElement | null, NestedMenuItemProps>(function NestedMenuItem(props, ref) {
    const {
        parentMenuOpen,
        label,
        rightIcon = <ArrowRight />,
        children,
        className,
        tabIndex: tabIndexProp,
        ContainerProps: ContainerPropsProp = {},
        ...MenuItemProps
    } = props;

    const {ref: containerRefProp, ...ContainerProps} = ContainerPropsProp;

    const menuItemRef = useRef<HTMLLIElement>(null);
    useImperativeHandle(ref, () => menuItemRef.current);

    const containerRef = useRef<HTMLDivElement>(null);
    useImperativeHandle(containerRefProp, () => containerRef.current);

    const menuContainerRef = useRef<HTMLDivElement>(null);

    const [isSubMenuOpen, setIsSubMenuOpen] = useState(false);

    const handleMouseEnter = (event: React.MouseEvent<HTMLElement>) => {
        setIsSubMenuOpen(true);

        if (ContainerProps?.onMouseEnter) {
            ContainerProps.onMouseEnter(event);
        }
    };
    const handleMouseLeave = (event: React.MouseEvent<HTMLElement>) => {
        setIsSubMenuOpen(false);

        if (ContainerProps?.onMouseLeave) {
            ContainerProps.onMouseLeave(event);
        }
    };

    // Check if any immediate children are active
    const isSubmenuFocused = () => {
        const active = containerRef.current?.ownerDocument?.activeElement;
        // @ts-ignore
        for (const child of menuContainerRef.current?.children ?? []) {
            if (child === active) {
                return true;
            }
        }
        return false;
    };

    const handleFocus = (event: React.FocusEvent<HTMLElement>) => {
        if (event.target === containerRef.current) {
            setIsSubMenuOpen(true);
        }

        if (ContainerProps?.onFocus) {
            ContainerProps.onFocus(event);
        }
    };

    const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
        if (event.key === 'Escape') {
            return;
        }

        if (isSubmenuFocused()) {
            event.stopPropagation();
        }

        const active = containerRef.current?.ownerDocument?.activeElement;

        if (event.key === 'ArrowLeft' && isSubmenuFocused()) {
            containerRef.current?.focus();
        }

        if (event.key === 'ArrowRight' && event.target === containerRef.current && event.target === active) {
            const firstChild = menuContainerRef.current?.children[0] as HTMLElement | undefined;
            firstChild?.focus();
        }
    };

    const open = isSubMenuOpen && parentMenuOpen;
    const menuItemClasses = useMenuItemStyles({open});

    // Root element must have a `tabIndex` attribute for keyboard navigation
    let tabIndex;
    if (!props.disabled) {
        tabIndex = tabIndexProp !== undefined ? tabIndexProp : -1;
    }

    return (
        <div
            {...ContainerProps}
            ref={containerRef}
            onFocus={handleFocus}
            tabIndex={tabIndex}
            onMouseEnter={handleMouseEnter}
            onMouseLeave={handleMouseLeave}
            onKeyDown={handleKeyDown}
        >
            <MenuItem {...MenuItemProps} className={clsx(menuItemClasses.root, className)} ref={menuItemRef}>
                {label}
                {rightIcon}
            </MenuItem>
            <Menu
                // Set pointer events to 'none' to prevent the invisible Popover div
                // from capturing events for clicks and hovers
                style={{pointerEvents: 'none'}}
                anchorEl={menuItemRef.current}
                anchorOrigin={{
                    vertical: 'top',
                    horizontal: 'right'
                }}
                transformOrigin={{
                    vertical: 'top',
                    horizontal: 'left'
                }}
                open={open}
                autoFocus={false}
                disableAutoFocus
                disableEnforceFocus
                onClose={() => {
                    setIsSubMenuOpen(false);
                }}
            >
                <div ref={menuContainerRef} style={{pointerEvents: 'auto'}}>
                    {children}
                </div>
            </Menu>
        </div>
    );
});

export default NestedMenuItem;

You can then import it like so :

import NestedMenuItem from 'src/components/NestedMenuItem';

elisherer commented 2 years ago

Thanks @RuellePaul, I edited your file:

import {
  forwardRef,
  useImperativeHandle,
  useRef,
  useState,
  FocusEvent,
  KeyboardEvent,
  MouseEvent,
  ElementType,
  ReactNode,
  HTMLAttributes,
  RefAttributes,
} from "react";
import { Menu, MenuItem, MenuItemProps, MenuProps, styled } from "@mui/material";
import { ArrowRight } from "@mui/icons-material";

export interface NestedMenuItemProps extends Omit<MenuItemProps, "button"> {
  /**
   * Open state of parent `<Menu />`, used to close descendent menus when the
   * root menu is closed.
   */
  parentMenuOpen: boolean;
  /**
   * Component for the container element.
   * @default 'div'
   */
  component?: ElementType;
  /**
   * Effectively becomes the `children` prop passed to the `<MenuItem/>`
   * element.
   */
  label?: ReactNode;
  /**
   * @default <ArrowRight />
   */
  rightIcon?: ReactNode;
  /**
   * Props passed to container element.
   */
  ContainerProps?: HTMLAttributes<HTMLElement> & RefAttributes<HTMLElement | null>;
  /**
   * Props passed to sub `<Menu/>` element
   */
  MenuProps?: Omit<MenuProps, "children">;
  /**
   * @see https://mui.com/api/list-item/
   */
  button?: true | undefined;
  /**
   *
   */
  rightAnchored?: boolean;
}

const TRANSPARENT = "rgba(0,0,0,0)";

const StyledMenuItem = styled(MenuItem)(({ theme }) => ({
  backgroundColor: TRANSPARENT,
  "&[data-open]": {
    backgroundColor: theme.palette.action.hover,
  },
}));

/**
 * Use as a drop-in replacement for `<MenuItem>` when you need to add cascading
 * menu elements as children to this component.
 */
const NestedMenuItem = forwardRef<HTMLLIElement | null, NestedMenuItemProps>(function NestedMenuItem(props, ref) {
  const {
    parentMenuOpen,
    label,
    rightIcon = <ArrowRight />,
    children,
    className,
    tabIndex: tabIndexProp,
    ContainerProps: ContainerPropsProp = {},
    rightAnchored,
    ...MenuItemProps
  } = props;

  const { ref: containerRefProp, ...ContainerProps } = ContainerPropsProp;

  const menuItemRef = useRef<HTMLLIElement>(null as unknown as HTMLLIElement);
  useImperativeHandle(ref, () => menuItemRef.current);

  const containerRef = useRef<HTMLDivElement>(null);
  useImperativeHandle(containerRefProp, () => containerRef.current);

  const menuContainerRef = useRef<HTMLDivElement>(null);

  const [isSubMenuOpen, setIsSubMenuOpen] = useState(false);

  const handleMouseEnter = (event: MouseEvent<HTMLElement>) => {
    setIsSubMenuOpen(true);

    if (ContainerProps?.onMouseEnter) {
      ContainerProps.onMouseEnter(event);
    }
  };
  const handleMouseLeave = (event: MouseEvent<HTMLElement>) => {
    setIsSubMenuOpen(false);

    if (ContainerProps?.onMouseLeave) {
      ContainerProps.onMouseLeave(event);
    }
  };

  // Check if any immediate children are active
  const isSubmenuFocused = () => {
    const active = containerRef.current?.ownerDocument?.activeElement;
    // @ts-ignore
    for (const child of menuContainerRef.current?.children ?? []) {
      if (child === active) {
        return true;
      }
    }
    return false;
  };

  const handleFocus = (event: FocusEvent<HTMLElement>) => {
    if (event.target === containerRef.current) {
      setIsSubMenuOpen(true);
    }

    if (ContainerProps?.onFocus) {
      ContainerProps.onFocus(event);
    }
  };

  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "Escape") {
      return;
    }

    if (isSubmenuFocused()) {
      event.stopPropagation();
    }

    const active = containerRef.current?.ownerDocument?.activeElement;

    if (event.key === "ArrowLeft" && isSubmenuFocused()) {
      containerRef.current?.focus();
    }

    if (event.key === "ArrowRight" && event.target === containerRef.current && event.target === active) {
      const firstChild = menuContainerRef.current?.children[0] as HTMLElement | undefined;
      firstChild?.focus();
    }
  };

  const open = isSubMenuOpen && parentMenuOpen;

  // Root element must have a `tabIndex` attribute for keyboard navigation
  let tabIndex;
  if (!props.disabled) {
    tabIndex = tabIndexProp !== undefined ? tabIndexProp : -1;
  }

  return (
    <div
      {...ContainerProps}
      ref={containerRef}
      onFocus={handleFocus}
      tabIndex={tabIndex}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
      onKeyDown={handleKeyDown}
    >
      <StyledMenuItem {...MenuItemProps} data-open={open || undefined} className={className} ref={menuItemRef}>
        {label}
        <div style={{ flexGrow: 1 }} />
        {rightIcon}
      </StyledMenuItem>
      <Menu
        // Set pointer events to 'none' to prevent the invisible Popover div
        // from capturing events for clicks and hovers
        style={{ pointerEvents: "none" }}
        anchorEl={menuItemRef.current}
        anchorOrigin={{
          vertical: "top",
          horizontal: rightAnchored ? "left" : "right",
        }}
        transformOrigin={{
          vertical: "top",
          horizontal: rightAnchored ? "right" : "left",
        }}
        open={open}
        autoFocus={false}
        disableAutoFocus
        disableEnforceFocus
        onClose={() => {
          setIsSubMenuOpen(false);
        }}
      >
        <div ref={menuContainerRef} style={{ pointerEvents: "auto" }}>
          {children}
        </div>
      </Menu>
    </div>
  );
});

export default NestedMenuItem;
jonenst commented 2 years ago

someone published this code in https://www.npmjs.com/package/material-ui-nested-menu-item-v5 do you know who ? Thanks