Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@ const App = () => {
<ContextMenu triggerId="context-menu-trigger">
<ContextMenu.Item disabled>Item 1</ContextMenu.Item>
<ContextMenu.Item onClick={handleClick}>Item 2</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onClick={handleClick}>Item 3</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.SubMenu label="Sub menu">
<ContextMenu.Item onClick={handleClick}>Sub item 1</ContextMenu.Item>
<ContextMenu.Item onClick={handleClick}>Sub item 2</ContextMenu.Item>
</ContextMenu.SubMenu>
<ContextMenu.Item onClick={handleClick}>Sub item 2</ContextMenu.Item>
</ContextMenu>
</>
);
Expand Down
8 changes: 5 additions & 3 deletions src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -126,5 +127,6 @@ const ContextMenu = ({ triggerId, children, animateExit = true }: ContextMenuPro

ContextMenu.Item = MenuItem;
ContextMenu.Separator = Separator;
ContextMenu.SubMenu = SubMenu;

export default ContextMenu;
1 change: 1 addition & 0 deletions src/components/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
97 changes: 97 additions & 0 deletions src/components/SubMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof setTimeout> | null>(null);

const itemRef = useRef<HTMLDivElement>(null);
const subMenuRef = useRef<HTMLDivElement>(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 (
<div
ref={itemRef}
className={classNames}
aria-haspopup="true"
role="menuitem"
tabIndex={-1}
onMouseEnter={calculatePosition}
onMouseLeave={onLeave}
onClick={(event: React.MouseEvent<HTMLElement>) => event.stopPropagation()}
>
<div className="react-context-menu__label">
{label}
<span className="react-context-menu__arrow" />
</div>
<div
ref={subMenuRef}
style={{
visibility: active ? 'visible' : 'hidden',
}}
className="react-context-menu__submenu"
>
{/* rest is sent from the ContextMenu element */}
{cloneChildren(children, rest as MenuItemExternalProps)}
</div>
</div>
);
};

export default SubMenu;
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
@@ -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';
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import './styles.css';

export { ContextMenu, MenuItem, Separator } from './components';
export { ContextMenu, MenuItem, Separator, SubMenu } from './components';
57 changes: 54 additions & 3 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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);
}
}
}
}
Expand All @@ -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);
}
4 changes: 2 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<any>, props));
Expand Down
Loading