diff --git a/app/main.tsx b/app/main.tsx index c210972..98a5110 100644 --- a/app/main.tsx +++ b/app/main.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './components'; -import '../src/styles/main.css'; +import '../src/styles.css'; import './styles/base.css'; createRoot(document.getElementById('root') as Element).render( diff --git a/package.json b/package.json index d86467b..7f3fd7c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "types": "dist/index.d.ts", "type": "module", "scripts": { - "start": "cd app && parcel", + "start": "cd app && npm run start", "watch": "parcel watch", "build": "parcel build", "lint": "eslint --report-unused-disable-directives --max-warnings 0", diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index f8d08c7..6c9b99c 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,4 +1,5 @@ import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import cx from 'clsx'; import MenuItem from './MenuItem'; import Separator from './Separator'; @@ -8,18 +9,21 @@ import { Position } from 'types'; interface ContextMenuProps { triggerId: string; children: ReactNode; + animateExit?: boolean; } interface ContextMenuState { active: boolean; + leaving: boolean; position: Position; } const HIDE_ON_EVENTS: (keyof GlobalEventHandlersEventMap)[] = ['click', 'resize', 'scroll', 'contextmenu']; -const ContextMenu = ({ triggerId, children }: ContextMenuProps) => { +const ContextMenu = ({ triggerId, children, animateExit = true }: ContextMenuProps) => { const [state, setState] = useState({ active: false, + leaving: false, position: { x: 0, y: 0 }, }); @@ -35,21 +39,42 @@ const ContextMenu = ({ triggerId, children }: ContextMenuProps) => { event.stopPropagation(); event.preventDefault(); - setState({ + setState((prev) => ({ + ...prev, active: true, position, - }); + })); }, [state.position], ); const hide = useCallback(() => { - setState({ - active: false, - position: { x: 0, y: 0 }, - }); + if (animateExit) { + setState((prev) => ({ + ...prev, + leaving: true, + })); + } else { + setState((prev) => ({ + ...prev, + active: false, + })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const handleAnimationEnd = useCallback(() => { + const { leaving, active } = state; + + if (leaving && active) { + setState((prev) => ({ + ...prev, + active: false, + leaving: false, + })); + } + }, [state]); + useEffect(() => { const { position } = state; @@ -78,18 +103,23 @@ const ContextMenu = ({ triggerId, children }: ContextMenuProps) => { if (!state.active) return null; + const classNames = cx('react-context-menu', { + 'react-context-menu--exit': state.leaving, + }); + return (
- {cloneChildren(children)} + {cloneChildren(children, { hide })}
); }; diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index d9b42a7..9e7ab83 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useCallback } from 'react'; +import { ReactNode, useState, useCallback } from 'react'; import cx from 'clsx'; export interface MenuItemProps { @@ -8,27 +8,57 @@ export interface MenuItemProps { children: ReactNode; } -const MenuItem = ({ children, onClick, disabled, className }: MenuItemProps) => { +export interface MenuItemExternalProps { + hide: () => void; +} + +interface MenuItemState { + clicked: boolean; + eventRef: React.MouseEvent | null; +} + +const MenuItem = ({ children, onClick, disabled, className, ...rest }: MenuItemProps) => { + const [state, setState] = useState({ clicked: false, eventRef: null }); + const handleClick = useCallback( (event: React.MouseEvent) => { - if (disabled) { - event.stopPropagation(); + event.stopPropagation(); - return; + if (!disabled && onClick) { + setState({ + clicked: true, + eventRef: event, + }); } - - if (onClick) onClick(event); }, // eslint-disable-next-line react-hooks/exhaustive-deps [onClick], ); + const handleAnimationEnd = useCallback(() => { + const { hide } = rest as MenuItemExternalProps; + + if (state.clicked && state.eventRef) { + hide(); + onClick!(state.eventRef); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.clicked, state.eventRef]); + const classNames = cx('react-context-menu__item', className, { - ['react-context-menu__item--disabled']: disabled, + 'react-context-menu__item--disabled': disabled, + 'react-context-menu__item--clicked': state.clicked, }); return ( -
+
{children}
); diff --git a/src/styles.css b/src/styles.css index 68e43a5..5770e39 100644 --- a/src/styles.css +++ b/src/styles.css @@ -6,6 +6,7 @@ --react-context-menu-background-color: #f2f2f2; --react-context-menu-border-color: #cccccc; + --react-context-menu-item-color: #2c2c2c; --react-context-menu-item-hover-color: #ffffff; --react-context-menu-item-hover-disabled-color: #999999; --react-context-menu-item-hover-background-color: #4095da; @@ -19,6 +20,42 @@ --react-context-menu-border-radius-outer: 6px; } +/* Animations */ + +@keyframes react-context-menu-exit { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +.react-context-menu--exit { + animation: react-context-menu-exit 150ms ease-out forwards; +} + +@keyframes react-context-menu__item-clicked { + 0% { + color: var(--react-context-menu-item-hover-color); + background-color: var(--react-context-menu-item-hover-background-color); + } + 50% { + color: var(--react-context-menu-item-color); + background-color: transparent; + } + 100% { + color: var(--react-context-menu-item-hover-color); + background-color: var(--react-context-menu-item-hover-background-color); + } +} + +.react-context-menu__item--clicked { + animation: react-context-menu__item-clicked 100ms ease-out forwards; +} + +/* Component styles */ + .react-context-menu { position: fixed; z-index: var(--react-context-menu-z-index); @@ -43,6 +80,7 @@ } .react-context-menu__item { + color: var(--react-context-menu-item-color); padding: var(--react-context-menu-padding-sm) var(--react-context-menu-padding-md); line-height: 1; diff --git a/src/utils.ts b/src/utils.ts index 7aef49f..fb5ec40 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,6 @@ import { Children, cloneElement, ReactElement, ReactNode } from 'react'; + +import { MenuItemExternalProps } from 'components/MenuItem'; import { Position } from 'types'; export const getCursorPosition = (e: MouseEvent): Position => { @@ -24,8 +26,8 @@ export const validateWindowPosition = (position: Position, element: HTMLDivEleme return { x, y }; }; -export const cloneChildren = (children: ReactNode) => { +export const cloneChildren = (children: ReactNode, props: MenuItemExternalProps) => { const filteredItems = Children.toArray(children).filter(Boolean); - return filteredItems.map((item) => cloneElement(item as ReactElement)); + return filteredItems.map((item) => cloneElement(item as ReactElement, props)); };