
웹 애플리케이션에서 컨텍스트 메뉴는 매우 중요한 UI 요소입니다. 특히, 사용자 경험을 향상시키기 위해 메뉴의 하위 메뉴(서브메뉴)를 구현하는 것이 필요할 때가 많습니다. 이번 글에서는 @floating-ui/react 라이브러리를 사용하여 Nested Submenu(중첩 서브메뉴)를 구현하는 방법을 소개하겠습니다.
먼저, 필요한 패키지를 설치합니다. @floating-ui/react와 Material-UI를 설치합니다.
npm install @floating-ui/react @mui/material @emotion/react @emotion/styled
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;
이제 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를 구현할 수 있습니다. 이 메뉴는 사용자가 특정 항목을 클릭하거나 포커스할 때 동적으로 나타나며, 중첩된 서브메뉴도 지원합니다. 필요에 따라 스타일을 커스터마이즈하거나 기능을 확장할 수 있습니다. 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.
구현 결과
