From 241c0767af34c6b80aead105d85f29e8f91afbf2 Mon Sep 17 00:00:00 2001 From: Tommy Sevaldsen Date: Wed, 22 Jan 2025 19:31:32 +0100 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20Add=20base=20for=20SubMenu=20co?= =?UTF-8?q?mponent=20with=20styling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SubMenu.tsx | 24 ++++++++++++++++++++++++ src/components/index.ts | 1 + src/styles.css | 22 ++++++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 src/components/SubMenu.tsx diff --git a/src/components/SubMenu.tsx b/src/components/SubMenu.tsx new file mode 100644 index 0000000..36687ae --- /dev/null +++ b/src/components/SubMenu.tsx @@ -0,0 +1,24 @@ +import cx from 'clsx'; + +export interface SubMenuProps { + label: string; + className?: string; + disabled?: boolean; +} + +const SubMenu = ({ label, className, disabled }: SubMenuProps) => { + const classNames = cx('react-context-menu__item', className, { + 'react-context-menu__item--disabled': disabled, + }); + + return ( +
) => event.stopPropagation()}> +
+ {label} + +
+
+ ); +}; + +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/styles.css b/src/styles.css index 5770e39..ff45bff 100644 --- a/src/styles.css +++ b/src/styles.css @@ -96,6 +96,10 @@ &: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 +109,21 @@ color: var(--react-context-menu-item-hover-disabled-color); } + +.react-context-menu__sub-menu { + 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); +} From 28ddcf58d75621a8d9f4603917539164eef3126d Mon Sep 17 00:00:00 2001 From: Tommy Sevaldsen Date: Wed, 22 Jan 2025 19:31:46 +0100 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=8E=A8=20Export=20SubMenu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ContextMenu.tsx | 2 ++ src/index.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 6c9b99c..b46c43a 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -3,6 +3,7 @@ import cx from 'clsx'; import MenuItem from './MenuItem'; import Separator from './Separator'; +import SubMenu from './SubMenu'; import { cloneChildren, getCursorPosition, validateWindowPosition } from '../utils'; import { Position } from 'types'; @@ -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/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'; From 4f48d82910761ce21b16d761a8368bdbd7b7cb11 Mon Sep 17 00:00:00 2001 From: Tommy Sevaldsen Date: Wed, 22 Jan 2025 19:48:02 +0100 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=90=9B=20Fix=20click=20animation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MenuItem.tsx | 1 + src/styles.css | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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/styles.css b/src/styles.css index ff45bff..fbf39bf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -51,7 +51,8 @@ } .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 */ From dbb494a599dccf8eff48fbaf31fb8b6a85d6b932 Mon Sep 17 00:00:00 2001 From: Tommy Sevaldsen Date: Thu, 23 Jan 2025 12:44:31 +0100 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=9A=9A=20Rename=20validateWindowPosit?= =?UTF-8?q?ion=20->=20validateMenuPosition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ContextMenu.tsx | 6 +++--- src/utils.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index b46c43a..0c82a41 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -4,7 +4,7 @@ import cx from 'clsx'; import MenuItem from './MenuItem'; import Separator from './Separator'; import SubMenu from './SubMenu'; -import { cloneChildren, getCursorPosition, validateWindowPosition } from '../utils'; +import { cloneChildren, getCursorPosition, validateMenuPosition } from '../utils'; import { Position } from 'types'; interface ContextMenuProps { @@ -33,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; @@ -82,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]); 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)); From 82bbadfb655dc9f5121ed563a4bdd01f1c4e8815 Mon Sep 17 00:00:00 2001 From: Tommy Sevaldsen Date: Thu, 23 Jan 2025 12:47:55 +0100 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=8E=A8=20Fix=20styling=20and=20positi?= =?UTF-8?q?oning=20for=20SubMenu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SubMenu.tsx | 80 ++++++++++++++++++++++++++++++++++++-- src/styles.css | 34 ++++++++++++++-- 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/components/SubMenu.tsx b/src/components/SubMenu.tsx index 36687ae..332ac62 100644 --- a/src/components/SubMenu.tsx +++ b/src/components/SubMenu.tsx @@ -1,22 +1,96 @@ +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 SubMenu = ({ label, className, disabled }: SubMenuProps) => { +const CLOSE_DELAY = 150; + +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('react-context-menu__submenu-right', 'react-context-menu__submenu-bottom'); + + const { height } = itemRef.current.getBoundingClientRect(); + const { right, bottom } = subMenuRef.current.getBoundingClientRect(); + + if (right > window.innerWidth) subMenuRef.current.classList.add('react-context-menu__submenu-right'); + if (bottom - window.innerHeight > 0) { + subMenuRef.current.style.top = `${window.innerHeight - bottom - height}px`; + subMenuRef.current.classList.add('react-context-menu__submenu-bottom'); + } + } + }, [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()}> -
+
{ + console.log('Mouse enter'); + calculatePosition(); + }} + onMouseLeave={onLeave} + onClick={(event: React.MouseEvent) => event.stopPropagation()} + > +
{label}
+
+ {/* rest is sent from the ContextMenu element */} + {cloneChildren(children, rest as MenuItemExternalProps)} +
); }; diff --git a/src/styles.css b/src/styles.css index fbf39bf..2f402ec 100644 --- a/src/styles.css +++ b/src/styles.css @@ -57,8 +57,8 @@ /* 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); @@ -71,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; @@ -91,6 +115,10 @@ user-select: none; -webkit-user-select: none; + &:has(.react-context-menu__submenu) { + position: relative; + } + &:not(.react-context-menu__item--disabled) { cursor: pointer; @@ -111,7 +139,7 @@ color: var(--react-context-menu-item-hover-disabled-color); } -.react-context-menu__sub-menu { +.react-context-menu__label { display: flex; align-items: center; justify-content: space-between; From f5903e4e6f1f0f189f27c6decd8804e0be69b12a Mon Sep 17 00:00:00 2001 From: Tommy Sevaldsen Date: Thu, 23 Jan 2025 12:50:17 +0100 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=8E=A8=20Add=20class=20consts=20+=20r?= =?UTF-8?q?emove=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SubMenu.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/components/SubMenu.tsx b/src/components/SubMenu.tsx index 332ac62..b05ad0e 100644 --- a/src/components/SubMenu.tsx +++ b/src/components/SubMenu.tsx @@ -12,6 +12,8 @@ export interface SubMenuProps { } 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); @@ -38,15 +40,15 @@ const SubMenu = ({ label, children, className, disabled, ...rest }: SubMenuProps // Reset position styling subMenuRef.current.style.top = '0'; - subMenuRef.current.classList.remove('react-context-menu__submenu-right', 'react-context-menu__submenu-bottom'); + 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('react-context-menu__submenu-right'); + 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('react-context-menu__submenu-bottom'); + subMenuRef.current.classList.add(BOTTOM_CLASS); } } }, [subMenuRef, itemRef, clearTimer]); @@ -70,10 +72,7 @@ const SubMenu = ({ label, children, className, disabled, ...rest }: SubMenuProps aria-haspopup="true" role="menuitem" tabIndex={-1} - onMouseEnter={() => { - console.log('Mouse enter'); - calculatePosition(); - }} + onMouseEnter={calculatePosition} onMouseLeave={onLeave} onClick={(event: React.MouseEvent) => event.stopPropagation()} > From d1abd1cf757d3958917c675dbbc33e840e62718d Mon Sep 17 00:00:00 2001 From: Tommy Sevaldsen Date: Thu, 23 Jan 2025 12:50:41 +0100 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=8E=A8=20Update=20app=20with=20sub=20?= =?UTF-8?q?menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/App.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 );