diff --git a/app/components/App.tsx b/app/components/App.tsx index 96853bc..f15e2fb 100644 --- a/app/components/App.tsx +++ b/app/components/App.tsx @@ -14,8 +14,13 @@ const App = () => { Item 1 Item 2 - Item 3 + + + Sub item 1 + Sub item 2 + + Sub item 2 ); diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 6c9b99c..0c82a41 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -3,7 +3,8 @@ import cx from 'clsx'; import MenuItem from './MenuItem'; import Separator from './Separator'; -import { cloneChildren, getCursorPosition, validateWindowPosition } from '../utils'; +import SubMenu from './SubMenu'; +import { cloneChildren, getCursorPosition, validateMenuPosition } from '../utils'; import { Position } from 'types'; interface ContextMenuProps { @@ -32,7 +33,7 @@ const ContextMenu = ({ triggerId, children, animateExit = true }: ContextMenuPro const show = useCallback( (event: MouseEvent) => { let position = getCursorPosition(event); - position = validateWindowPosition(position, contextMenuRef.current); + position = validateMenuPosition(position, contextMenuRef.current); if (JSON.stringify(state.position) === JSON.stringify(position)) return; @@ -81,7 +82,7 @@ const ContextMenu = ({ triggerId, children, animateExit = true }: ContextMenuPro if (state.active) setState((prev) => ({ ...prev, - position: validateWindowPosition(position, contextMenuRef.current), + position: validateMenuPosition(position, contextMenuRef.current), })); // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.active]); @@ -126,5 +127,6 @@ const ContextMenu = ({ triggerId, children, animateExit = true }: ContextMenuPro ContextMenu.Item = MenuItem; ContextMenu.Separator = Separator; +ContextMenu.SubMenu = SubMenu; export default ContextMenu; diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 9e7ab83..66bf685 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -37,6 +37,7 @@ const MenuItem = ({ children, onClick, disabled, className, ...rest }: MenuItemP const handleAnimationEnd = useCallback(() => { const { hide } = rest as MenuItemExternalProps; + setState((prev) => ({ ...prev, clicked: false })); if (state.clicked && state.eventRef) { hide(); diff --git a/src/components/SubMenu.tsx b/src/components/SubMenu.tsx new file mode 100644 index 0000000..b05ad0e --- /dev/null +++ b/src/components/SubMenu.tsx @@ -0,0 +1,97 @@ +import { ReactNode, useState, useEffect, useCallback, useRef } from 'react'; +import cx from 'clsx'; + +import { cloneChildren } from '../utils'; +import { MenuItemExternalProps } from './MenuItem'; + +export interface SubMenuProps { + label: string; + children: ReactNode; + className?: string; + disabled?: boolean; +} + +const CLOSE_DELAY = 150; +const RIGHT_CLASS = 'react-context-menu__submenu-right'; +const BOTTOM_CLASS = 'react-context-menu__submenu-bottom'; + +const SubMenu = ({ label, children, className, disabled, ...rest }: SubMenuProps) => { + const [active, setActive] = useState(false); + + const timeoutRef = useRef | null>(null); + + const itemRef = useRef(null); + const subMenuRef = useRef(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + const clearTimer = useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }, []); + + const calculatePosition = useCallback(() => { + if (subMenuRef.current && itemRef.current) { + clearTimer(); + setActive(true); + + // Reset position styling + subMenuRef.current.style.top = '0'; + subMenuRef.current.classList.remove(RIGHT_CLASS, BOTTOM_CLASS); + + const { height } = itemRef.current.getBoundingClientRect(); + const { right, bottom } = subMenuRef.current.getBoundingClientRect(); + + if (right > window.innerWidth) subMenuRef.current.classList.add(RIGHT_CLASS); + if (bottom - window.innerHeight > 0) { + subMenuRef.current.style.top = `${window.innerHeight - bottom - height}px`; + subMenuRef.current.classList.add(BOTTOM_CLASS); + } + } + }, [subMenuRef, itemRef, clearTimer]); + + const onLeave = useCallback(() => { + clearTimer(); + + timeoutRef.current = setTimeout(() => { + setActive(false); + }, CLOSE_DELAY); + }, [clearTimer]); + + const classNames = cx('react-context-menu__item', className, { + 'react-context-menu__item--disabled': disabled, + }); + + return ( +
) => event.stopPropagation()} + > +
+ {label} + +
+
+ {/* rest is sent from the ContextMenu element */} + {cloneChildren(children, rest as MenuItemExternalProps)} +
+
+ ); +}; + +export default SubMenu; diff --git a/src/components/index.ts b/src/components/index.ts index c44a072..3451eee 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,3 +1,4 @@ export { default as ContextMenu } from './ContextMenu'; export { default as MenuItem } from './MenuItem'; export { default as Separator } from './Separator'; +export { default as SubMenu } from './SubMenu'; diff --git a/src/index.ts b/src/index.ts index 5a62336..daaa1be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ import './styles.css'; -export { ContextMenu, MenuItem, Separator } from './components'; +export { ContextMenu, MenuItem, Separator, SubMenu } from './components'; diff --git a/src/styles.css b/src/styles.css index 5770e39..2f402ec 100644 --- a/src/styles.css +++ b/src/styles.css @@ -51,13 +51,14 @@ } .react-context-menu__item--clicked { - animation: react-context-menu__item-clicked 100ms ease-out forwards; + animation: react-context-menu__item-clicked 100ms ease-out; + animation-iteration-count: 1; } /* Component styles */ -.react-context-menu { - position: fixed; +.react-context-menu, +.react-context-menu__submenu { z-index: var(--react-context-menu-z-index); padding: var(--react-context-menu-padding-sm); @@ -70,6 +71,30 @@ min-width: 160px; } +.react-context-menu { + position: fixed; +} + +.react-context-menu__submenu { + position: absolute; + + /* Initial position */ + left: 100%; + + &:not(.react-context-menu__submenu-bottom) { + top: calc(-1 * var(--react-context-menu-padding-sm)); + } +} + +.react-context-menu__submenu-bottom { + top: unset; +} + +.react-context-menu__submenu-right { + right: 100%; + left: unset; +} + .react-context-menu__separator { border: 0; margin-block: 0; @@ -90,12 +115,20 @@ user-select: none; -webkit-user-select: none; + &:has(.react-context-menu__submenu) { + position: relative; + } + &:not(.react-context-menu__item--disabled) { cursor: pointer; &:hover { color: var(--react-context-menu-item-hover-color); background-color: var(--react-context-menu-item-hover-background-color); + + .react-context-menu__arrow { + border-color: var(--react-context-menu-item-hover-color); + } } } } @@ -105,3 +138,21 @@ color: var(--react-context-menu-item-hover-disabled-color); } + +.react-context-menu__label { + display: flex; + align-items: center; + justify-content: space-between; +} + +.react-context-menu__arrow { + transform: rotate(-45deg); + + width: 5px; + height: 5px; + padding: 3px; + + border-style: solid; + border-width: 0 2px 2px 0; + border-color: var(--react-context-menu-item-color); +} diff --git a/src/utils.ts b/src/utils.ts index fb5ec40..049bf50 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -12,7 +12,7 @@ export const getCursorPosition = (e: MouseEvent): Position => { return position; }; -export const validateWindowPosition = (position: Position, element: HTMLDivElement | null) => { +export const validateMenuPosition = (position: Position, element: HTMLDivElement | null) => { if (!element) return position; let { x, y } = position; @@ -26,7 +26,7 @@ export const validateWindowPosition = (position: Position, element: HTMLDivEleme return { x, y }; }; -export const cloneChildren = (children: ReactNode, props: MenuItemExternalProps) => { +export const cloneChildren = (children: ReactNode, props?: MenuItemExternalProps) => { const filteredItems = Children.toArray(children).filter(Boolean); return filteredItems.map((item) => cloneElement(item as ReactElement, props));