"@floating-ui/react"를 기반으로 한 Nested Submenu Dropdown 구현하기

kiwon kim·2024년 5월 31일

Frontend

목록 보기
1/30
post-thumbnail

웹 애플리케이션에서 컨텍스트 메뉴는 매우 중요한 UI 요소입니다. 특히, 사용자 경험을 향상시키기 위해 메뉴의 하위 메뉴(서브메뉴)를 구현하는 것이 필요할 때가 많습니다. 이번 글에서는 @floating-ui/react 라이브러리를 사용하여 Nested Submenu(중첩 서브메뉴)를 구현하는 방법을 소개하겠습니다.

1. 프로젝트 설정

먼저, 필요한 패키지를 설치합니다. @floating-ui/react와 Material-UI를 설치합니다.

npm install @floating-ui/react @mui/material @emotion/react @emotion/styled

2. 기본 구조 작성

Nested Submenu를 구성하는 데 필요한 세 가지 주요 컴포넌트를 작성합니다: Menu, MenuItem, MenuItemStyle.

MenuItemStyle 컴포넌트는 각 메뉴 항목의 스타일을 정의합니다. 메뉴 항목의 크기(size)에 따라 다른 스타일을 적용합니다.

import { Grid } from "@mui/material";
import { forwardRef } from "react";
import { MenuProps } from "./Menu";

const getSizeStyles = (size: MenuProps["size"]) => {
  switch (size) {
    case "small":
      return { height: "26px" };
    case "large":
      return { height: "36px" };
    case "medium":
    default:
      return { height: "30px" };
  }
};

const MenuItemStyle = forwardRef<
  HTMLDivElement,
  Omit<MenuProps, "renderLabel" | "menuKey">
>(({ size, sx, isRoot, ...restProps }, ref) => {
  return (
    <Grid
      ref={ref}
      {...restProps}
      sx={{
        ...getSizeStyles(size),
        display: "flex",
        alignItems: "center",
        padding: "4px",
        borderRadius: "6px",
        ...sx,
        "&:hover": {
          background: isRoot ? "white" : "gray",
        },
      }}
    />
  );
});

export default MenuItemStyle;

MenuItem 컴포넌트는 실제로 메뉴 항목을 렌더링합니다. 각 항목이 클릭되거나 포커스될 때 이벤트를 처리합니다.

import { useFloatingTree, useListItem, useMergeRefs } from "@floating-ui/react";
import * as React from "react";
import MenuItemStyle from "./MenuItemStyle";

const MenuContext = React.createContext<{
  getItemProps: (
    userProps?: React.HTMLProps<HTMLElement>
  ) => Record<string, unknown>;
  activeIndex: number | null;
  setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>;
  setHasFocusInside: React.Dispatch<React.SetStateAction<boolean>>;
  isOpen: boolean;
}>({
  getItemProps: () => ({}),
  activeIndex: null,
  setActiveIndex: () => {},
  setHasFocusInside: () => {},
  isOpen: false,
});

interface MenuItemProps {
  menuKey: string; // Unique string identifier
  renderLabel: () => React.ReactNode;
  disabled?: boolean;
}

export const MenuItem = React.forwardRef<
  HTMLDivElement,
  MenuItemProps & React.HTMLAttributes<HTMLDivElement>
>(({ menuKey, renderLabel, disabled, ...props }, forwardedRef) => {
  const menu = React.useContext(MenuContext);
  const item = useListItem({ label: disabled ? null : menuKey });
  const tree = useFloatingTree();
  const isActive = item.index === menu.activeIndex;

  const menuItemTabRef = useMergeRefs([item.ref, forwardedRef]);

  return (
    <MenuItemStyle
      {...props}
      ref={menuItemTabRef}
      role="menuitem"
      className="MenuItem"
      tabIndex={isActive ? 0 : -1}
      aria-disabled={disabled}
      {...menu.getItemProps({
        onClick(event: React.MouseEvent<HTMLDivElement>) {
          props.onClick?.(event);
          tree?.events.emit("click");
        },
        onFocus(event: React.FocusEvent<HTMLDivElement>) {
          props.onFocus?.(event);
          menu.setHasFocusInside(true);
        },
      })}
    >
      {renderLabel()}
    </MenuItemStyle>
  );
});
import {
  autoUpdate,  // 요소의 위치를 자동으로 업데이트합니다.
  flip,  // 요소가 화면 밖으로 나가면 반대편으로 이동시킵니다.
  FloatingFocusManager,  // 포커스 관리를 위해 사용합니다.
  FloatingList,  // 리스트 형태의 요소를 관리합니다.
  FloatingNode,  // 플로팅 요소의 노드 트리를 관리합니다.
  FloatingPortal,  // 포털을 통해 플로팅 요소를 렌더링합니다.
  FloatingTree,  // 플로팅 요소의 트리를 관리합니다.
  offset,  // 요소의 위치를 오프셋합니다.
  safePolygon,  // 마우스가 빠르게 이동할 때 요소가 닫히지 않도록 합니다.
  shift,  // 요소가 화면 밖으로 나가는 것을 방지하고 위치를 조정합니다.
  useClick,  // 클릭 이벤트를 처리합니다.
  useDismiss,  // 요소를 닫는 역할을 합니다.
  useFloating,  // 플로팅 요소의 위치와 상태를 관리합니다.
  useFloatingNodeId,  // 플로팅 노드의 ID를 생성하고 관리합니다.
  useFloatingParentNodeId,  // 부모 플로팅 노드의 ID를 가져옵니다.
  useFloatingTree,  // 플로팅 트리 컨텍스트를 제공합니다.
  useHover,  // 호버 이벤트를 처리합니다.
  useInteractions,  // 여러 상호작용 훅을 결합합니다.
  useListItem,  // 리스트 항목을 관리합니다.
  useListNavigation,  // 리스트 항목 간의 키보드 내비게이션을 처리합니다.
  useMergeRefs,  // 여러 참조를 병합합니다.
  useRole,  // 요소의 역할을 설정합니다.
  useTypeahead,  // 입력한 문자에 따라 항목을 빠르게 찾습니다.
} from "@floating-ui/react";
import * as React from "react";
import { Grid, GridProps, SxProps, Theme } from "@mui/material";
import MenuItemStyle from "./MenuItemStyle";
import { MenuItem } from "./MenuItem";

const MenuContext = React.createContext<{
  getItemProps: (
    userProps?: React.HTMLProps<HTMLElement>
  ) => Record<string, unknown>;
  activeIndex: number | null;
  setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>;
  setHasFocusInside: React.Dispatch<React.SetStateAction<boolean>>;
  isOpen: boolean;
}>({
  getItemProps: () => ({}),
  activeIndex: null,
  setActiveIndex: () => {},
  setHasFocusInside: () => {},
  isOpen: false,
});

export type MenuProps = Omit<React.HTMLProps<HTMLDivElement>, "size"> &
  Omit<GridProps, "size"> & {
    renderLabel: () => React.ReactNode;
    nested?: boolean;
    children?: React.ReactNode;
    size?: "small" | "medium" | "large";
    menuKey: string;
    sx?: SxProps<Theme>;
    isRoot?: boolean;
  };

const MenuComponent = React.forwardRef<HTMLDivElement, MenuProps>(
  (
    { renderLabel, children, size = "medium", menuKey, ...props },
    forwardedRef
  ) => {
    const [isOpen, setIsOpen] = React.useState(false);
    const [hasFocusInside, setHasFocusInside] = React.useState(false);
    const [activeIndex, setActiveIndex] = React.useState<number | null>(null);

    const elementsRef = React.useRef<Array<HTMLDivElement | null>>([]);
    const labelsRef = React.useRef<Array<string | null>>([]);
    const parent = React.useContext(MenuContext);

    const tree = useFloatingTree();  // 플로팅 트리 컨텍스트를 제공합니다.
    const nodeId = useFloatingNodeId();  // 플로팅 노드의 ID를 생성하고 관리합니다.
    const parentId = useFloatingParentNodeId();  // 부모 플로팅 노드의 ID를 가져옵니다.
    const item = useListItem({ label: menuKey });  // 리스트 항목을 관리합니다.

    const isNested = parentId != null;

    const { floatingStyles, refs, context } = useFloating<HTMLDivElement>({
      nodeId,
      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,
      delay: { open: 75 },
      handleClose: safePolygon({ blockPointerEvents: true }),  // 마우스가 빠르게 이동할 때 요소가 닫히지 않도록 합니다.
    });
    const click = useClick(context, {
      event: "mousedown",
      toggle: !isNested,
      ignoreMouse: isNested,
    });
    const role = useRole(context, { role: "menu" });  // 요소의 역할을 설정합니다.
    const dismiss = useDismiss(context, { bubbles: true });  // 요소를 닫는 역할을 합니다.
    const listNavigation = useListNavigation(context, {
      listRef: elementsRef,
      activeIndex,
      nested: isNested,
      onNavigate: setActiveIndex,
    });
    const typeahead = useTypeahead(context, {
      listRef: labelsRef,
      onMatch: isOpen ? setActiveIndex : undefined,
      activeIndex,
    });

    const { getReferenceProps, getFloatingProps, getItemProps } =
      useInteractions([hover, click, role, dismiss, listNavigation, typeahead]);  // 여러 상호작용 훅을 결합합니다.

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

      function handleTreeClick() {
        setIsOpen(false);
      }

      function onSubMenuOpen(event: { nodeId: string; parentId: string }) {
        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]);

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

    const menuTabRef = useMergeRefs([refs.setReference, item.ref, forwardedRef]);  // 여러 참조를 병합합니다.

    const renderMenuTab = () => {
      const menuStyle: SxProps<Theme> = isNested
        ? { border: "none" }
        : {
            background: "none",
            borderRadius: "6px",
            border: "1px solid gray",
            display: "flex",
            justifyContent: "space-between",
            alignItems: "center",
            minWidth: "110px",
          };

      return (
        <MenuItemStyle
          sx={{ ...menuStyle }}
          size={size}
          isRoot={!isNested}
          ref={menuTabRef}
          tabIndex={!isNested ? undefined : parent.activeIndex === item.index ? 0 : -1}
          role={isNested ? "menuitem" : undefined}
          data-open={isOpen ? "" : undefined}
          data-nested={isNested ? "" : undefined}
          data-focus-inside={hasFocusInside ? "" : undefined}
          className={isNested ? "MenuItem" : "RootMenu"}
          {...getReferenceProps(
            parent.getItemProps({
              ...props,
              onFocus(event: React.FocusEvent<HTMLDivElement>) {
                props.onFocus?.(event);
                setHasFocusInside(false);
                parent.setHasFocusInside(true);
              },
            })
          )}
        >
          {renderLabel()}
          {isNested && <span aria-hidden style={{ marginLeft: 10, fontSize: 10 }}></span>}
        </MenuItemStyle>
      );
    };

    const renderMenuListProvider = () => {
      return (
        <MenuContext.Provider
          value={{
            activeIndex,
            setActiveIndex,
            getItemProps,
            setHasFocusInside,
            isOpen,
          }}
        >
          <FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>  // 리스트 형태의 요소를 관리합니다.
            {isOpen && (
              <FloatingPortal>  // 포털을 통해 플로팅 요소를 렌더링합니다.
                <FloatingFocusManager
                  context={context}
                  modal={false}
                  initialFocus={isNested ? -1 : 0}
                  returnFocus={!isNested}
                >
                  <Grid
                    ref={refs.setFloating}
                    className="Menu"
                    sx={{
                      background: "rgba(255,

 255, 255, 0.8)",
                      backdropFilter: "blur(10px)",
                      padding: "4px",
                      borderRadius: "6px",
                      boxShadow: "2px 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.1)",
                      outline: 0,
                    }}
                    style={floatingStyles}
                    {...getFloatingProps()}
                  >
                    {children}
                  </Grid>
                </FloatingFocusManager>
              </FloatingPortal>
            )}
          </FloatingList>
        </MenuContext.Provider>
      );
    };

    return (
      <FloatingNode id={nodeId}>  // 플로팅 요소의 노드 트리를 관리합니다.
        {renderMenuTab()}
        {renderMenuListProvider()}
      </FloatingNode>
    );
  }
);

const MenuElement = React.forwardRef<
  HTMLDivElement,
  MenuProps & Omit<React.HTMLProps<HTMLDivElement>, "size">
>((props, ref) => {
  const parentId = useFloatingParentNodeId();  // 부모 플로팅 노드의 ID를 가져옵니다.

  if (parentId === null) {
    return (
      <FloatingTree>  // 플로팅 요소의 트리를 관리합니다.
        <MenuComponent {...props} ref={ref} />
      </FloatingTree>
    );
  }

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

const Menu = Object.assign(MenuElement, {
  Root: MenuElement,
  Item: MenuItem,
});

export default Menu;

3. 애플리케이션에 적용

이제 Menu 컴포넌트를 애플리케이션에 적용하여 중첩 서브메뉴를 구현합니다.

// App.tsx

import Menu from "./components/Menu"; // 프로젝트 구조에 맞게 경로를 조정하세요

export default function App() {
  return (
    <>
      <Menu.Root
        menuKey="edit"
        renderLabel={() => <span style={{ color: "blue" }}>Edit</span>}
        size="large" // 사이즈 지정
      >
        <Menu.Item
          menuKey="undo"
          renderLabel={() => <span>Undo</span>}
          onClick={() => console.log("Undo")}
        />
        <Menu.Item
          menuKey="redo"
          renderLabel={() => <span>Redo</span>}
          disabled
        />
        <Menu.Item menuKey="cut" renderLabel={() => <span>Cut</span>} />
        <Menu.Root
          menuKey="copy-as"
          renderLabel={() => <span style={{ color: "blue" }}>Copy As</span>}
          size="medium" // 사이즈 지정
        >
          <Menu.Item menuKey="text" renderLabel={() => <span>Text</span>} />
          <Menu.Item menuKey="video" renderLabel={() => <span>Video</span>} />
          <Menu.Root
            menuKey="image"
            renderLabel={() => <span style={{ color: "blue" }}>Image</span>}
            size="medium" // 사이즈 지정
          >
            <Menu.Item menuKey="png" renderLabel={() => <span>.png</span>} />
            <Menu.Item menuKey="jpg" renderLabel={() => <span>.jpg</span>} />
            <Menu.Item menuKey="svg" renderLabel={() => <span>.svg</span>} />
            <Menu.Item menuKey="gif" renderLabel={() => <span>.gif</span>} />
          </Menu.Root>
          <Menu.Item menuKey="audio" renderLabel={() => <span>Audio</span>} />
        </Menu.Root>
        <Menu.Root
          menuKey="share"
          renderLabel={() => <span style={{ color: "blue" }}>Share</span>}
          size="medium" // 사이즈 지정
        >
          <Menu.Item menuKey="mail" renderLabel={() => <span>Mail</span>} />
          <Menu.Item
            menuKey="instagram"
            renderLabel={() => <span>Instagram</span>}
          />
        </Menu.Root>
      </Menu.Root>
    </>
  );
}

이렇게 하면 @floating-ui/react를 사용하여 Nested Submenu를 구현할 수 있습니다. 이 메뉴는 사용자가 특정 항목을 클릭하거나 포커스할 때 동적으로 나타나며, 중첩된 서브메뉴도 지원합니다. 필요에 따라 스타일을 커스터마이즈하거나 기능을 확장할 수 있습니다. 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.

구현 결과

profile
FOR_THE_BEST_DEVELOPER

0개의 댓글