Skip to content

Commit d81b064

Browse files
authored
✨ Sub menu (#2)
* ✨ Add base for SubMenu component with styling * 🎨 Export SubMenu * πŸ› Fix click animation * 🚚 Rename validateWindowPosition -> validateMenuPosition * 🎨 Fix styling and positioning for SubMenu * 🎨 Add class consts + remove log * 🎨 Update app with sub menu
1 parent af28782 commit d81b064

File tree

8 files changed

+167
-10
lines changed

8 files changed

+167
-10
lines changed

β€Žapp/components/App.tsxβ€Ž

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,13 @@ const App = () => {
1414
<ContextMenu triggerId="context-menu-trigger">
1515
<ContextMenu.Item disabled>Item 1</ContextMenu.Item>
1616
<ContextMenu.Item onClick={handleClick}>Item 2</ContextMenu.Item>
17-
<ContextMenu.Separator />
1817
<ContextMenu.Item onClick={handleClick}>Item 3</ContextMenu.Item>
18+
<ContextMenu.Separator />
19+
<ContextMenu.SubMenu label="Sub menu">
20+
<ContextMenu.Item onClick={handleClick}>Sub item 1</ContextMenu.Item>
21+
<ContextMenu.Item onClick={handleClick}>Sub item 2</ContextMenu.Item>
22+
</ContextMenu.SubMenu>
23+
<ContextMenu.Item onClick={handleClick}>Sub item 2</ContextMenu.Item>
1924
</ContextMenu>
2025
</>
2126
);

β€Žsrc/components/ContextMenu.tsxβ€Ž

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import cx from 'clsx';
33

44
import MenuItem from './MenuItem';
55
import Separator from './Separator';
6-
import { cloneChildren, getCursorPosition, validateWindowPosition } from '../utils';
6+
import SubMenu from './SubMenu';
7+
import { cloneChildren, getCursorPosition, validateMenuPosition } from '../utils';
78
import { Position } from 'types';
89

910
interface ContextMenuProps {
@@ -32,7 +33,7 @@ const ContextMenu = ({ triggerId, children, animateExit = true }: ContextMenuPro
3233
const show = useCallback(
3334
(event: MouseEvent) => {
3435
let position = getCursorPosition(event);
35-
position = validateWindowPosition(position, contextMenuRef.current);
36+
position = validateMenuPosition(position, contextMenuRef.current);
3637

3738
if (JSON.stringify(state.position) === JSON.stringify(position)) return;
3839

@@ -81,7 +82,7 @@ const ContextMenu = ({ triggerId, children, animateExit = true }: ContextMenuPro
8182
if (state.active)
8283
setState((prev) => ({
8384
...prev,
84-
position: validateWindowPosition(position, contextMenuRef.current),
85+
position: validateMenuPosition(position, contextMenuRef.current),
8586
}));
8687
// eslint-disable-next-line react-hooks/exhaustive-deps
8788
}, [state.active]);
@@ -126,5 +127,6 @@ const ContextMenu = ({ triggerId, children, animateExit = true }: ContextMenuPro
126127

127128
ContextMenu.Item = MenuItem;
128129
ContextMenu.Separator = Separator;
130+
ContextMenu.SubMenu = SubMenu;
129131

130132
export default ContextMenu;

β€Žsrc/components/MenuItem.tsxβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const MenuItem = ({ children, onClick, disabled, className, ...rest }: MenuItemP
3737

3838
const handleAnimationEnd = useCallback(() => {
3939
const { hide } = rest as MenuItemExternalProps;
40+
setState((prev) => ({ ...prev, clicked: false }));
4041

4142
if (state.clicked && state.eventRef) {
4243
hide();

β€Žsrc/components/SubMenu.tsxβ€Ž

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { ReactNode, useState, useEffect, useCallback, useRef } from 'react';
2+
import cx from 'clsx';
3+
4+
import { cloneChildren } from '../utils';
5+
import { MenuItemExternalProps } from './MenuItem';
6+
7+
export interface SubMenuProps {
8+
label: string;
9+
children: ReactNode;
10+
className?: string;
11+
disabled?: boolean;
12+
}
13+
14+
const CLOSE_DELAY = 150;
15+
const RIGHT_CLASS = 'react-context-menu__submenu-right';
16+
const BOTTOM_CLASS = 'react-context-menu__submenu-bottom';
17+
18+
const SubMenu = ({ label, children, className, disabled, ...rest }: SubMenuProps) => {
19+
const [active, setActive] = useState(false);
20+
21+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
22+
23+
const itemRef = useRef<HTMLDivElement>(null);
24+
const subMenuRef = useRef<HTMLDivElement>(null);
25+
26+
useEffect(() => {
27+
return () => {
28+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
29+
};
30+
}, []);
31+
32+
const clearTimer = useCallback(() => {
33+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
34+
}, []);
35+
36+
const calculatePosition = useCallback(() => {
37+
if (subMenuRef.current && itemRef.current) {
38+
clearTimer();
39+
setActive(true);
40+
41+
// Reset position styling
42+
subMenuRef.current.style.top = '0';
43+
subMenuRef.current.classList.remove(RIGHT_CLASS, BOTTOM_CLASS);
44+
45+
const { height } = itemRef.current.getBoundingClientRect();
46+
const { right, bottom } = subMenuRef.current.getBoundingClientRect();
47+
48+
if (right > window.innerWidth) subMenuRef.current.classList.add(RIGHT_CLASS);
49+
if (bottom - window.innerHeight > 0) {
50+
subMenuRef.current.style.top = `${window.innerHeight - bottom - height}px`;
51+
subMenuRef.current.classList.add(BOTTOM_CLASS);
52+
}
53+
}
54+
}, [subMenuRef, itemRef, clearTimer]);
55+
56+
const onLeave = useCallback(() => {
57+
clearTimer();
58+
59+
timeoutRef.current = setTimeout(() => {
60+
setActive(false);
61+
}, CLOSE_DELAY);
62+
}, [clearTimer]);
63+
64+
const classNames = cx('react-context-menu__item', className, {
65+
'react-context-menu__item--disabled': disabled,
66+
});
67+
68+
return (
69+
<div
70+
ref={itemRef}
71+
className={classNames}
72+
aria-haspopup="true"
73+
role="menuitem"
74+
tabIndex={-1}
75+
onMouseEnter={calculatePosition}
76+
onMouseLeave={onLeave}
77+
onClick={(event: React.MouseEvent<HTMLElement>) => event.stopPropagation()}
78+
>
79+
<div className="react-context-menu__label">
80+
{label}
81+
<span className="react-context-menu__arrow" />
82+
</div>
83+
<div
84+
ref={subMenuRef}
85+
style={{
86+
visibility: active ? 'visible' : 'hidden',
87+
}}
88+
className="react-context-menu__submenu"
89+
>
90+
{/* rest is sent from the ContextMenu element */}
91+
{cloneChildren(children, rest as MenuItemExternalProps)}
92+
</div>
93+
</div>
94+
);
95+
};
96+
97+
export default SubMenu;

β€Žsrc/components/index.tsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { default as ContextMenu } from './ContextMenu';
22
export { default as MenuItem } from './MenuItem';
33
export { default as Separator } from './Separator';
4+
export { default as SubMenu } from './SubMenu';

β€Žsrc/index.tsβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
import './styles.css';
22

3-
export { ContextMenu, MenuItem, Separator } from './components';
3+
export { ContextMenu, MenuItem, Separator, SubMenu } from './components';

β€Žsrc/styles.cssβ€Ž

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,14 @@
5151
}
5252

5353
.react-context-menu__item--clicked {
54-
animation: react-context-menu__item-clicked 100ms ease-out forwards;
54+
animation: react-context-menu__item-clicked 100ms ease-out;
55+
animation-iteration-count: 1;
5556
}
5657

5758
/* Component styles */
5859

59-
.react-context-menu {
60-
position: fixed;
60+
.react-context-menu,
61+
.react-context-menu__submenu {
6162
z-index: var(--react-context-menu-z-index);
6263

6364
padding: var(--react-context-menu-padding-sm);
@@ -70,6 +71,30 @@
7071
min-width: 160px;
7172
}
7273

74+
.react-context-menu {
75+
position: fixed;
76+
}
77+
78+
.react-context-menu__submenu {
79+
position: absolute;
80+
81+
/* Initial position */
82+
left: 100%;
83+
84+
&:not(.react-context-menu__submenu-bottom) {
85+
top: calc(-1 * var(--react-context-menu-padding-sm));
86+
}
87+
}
88+
89+
.react-context-menu__submenu-bottom {
90+
top: unset;
91+
}
92+
93+
.react-context-menu__submenu-right {
94+
right: 100%;
95+
left: unset;
96+
}
97+
7398
.react-context-menu__separator {
7499
border: 0;
75100
margin-block: 0;
@@ -90,12 +115,20 @@
90115
user-select: none;
91116
-webkit-user-select: none;
92117

118+
&:has(.react-context-menu__submenu) {
119+
position: relative;
120+
}
121+
93122
&:not(.react-context-menu__item--disabled) {
94123
cursor: pointer;
95124

96125
&:hover {
97126
color: var(--react-context-menu-item-hover-color);
98127
background-color: var(--react-context-menu-item-hover-background-color);
128+
129+
.react-context-menu__arrow {
130+
border-color: var(--react-context-menu-item-hover-color);
131+
}
99132
}
100133
}
101134
}
@@ -105,3 +138,21 @@
105138

106139
color: var(--react-context-menu-item-hover-disabled-color);
107140
}
141+
142+
.react-context-menu__label {
143+
display: flex;
144+
align-items: center;
145+
justify-content: space-between;
146+
}
147+
148+
.react-context-menu__arrow {
149+
transform: rotate(-45deg);
150+
151+
width: 5px;
152+
height: 5px;
153+
padding: 3px;
154+
155+
border-style: solid;
156+
border-width: 0 2px 2px 0;
157+
border-color: var(--react-context-menu-item-color);
158+
}

β€Žsrc/utils.tsβ€Ž

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const getCursorPosition = (e: MouseEvent): Position => {
1212
return position;
1313
};
1414

15-
export const validateWindowPosition = (position: Position, element: HTMLDivElement | null) => {
15+
export const validateMenuPosition = (position: Position, element: HTMLDivElement | null) => {
1616
if (!element) return position;
1717

1818
let { x, y } = position;
@@ -26,7 +26,7 @@ export const validateWindowPosition = (position: Position, element: HTMLDivEleme
2626
return { x, y };
2727
};
2828

29-
export const cloneChildren = (children: ReactNode, props: MenuItemExternalProps) => {
29+
export const cloneChildren = (children: ReactNode, props?: MenuItemExternalProps) => {
3030
const filteredItems = Children.toArray(children).filter(Boolean);
3131

3232
return filteredItems.map((item) => cloneElement(item as ReactElement<any>, props));

0 commit comments

Comments
Β (0)