From 781a1c267379058016c0ace2d454986d56f1885d Mon Sep 17 00:00:00 2001 From: Eelco Wiersma Date: Sat, 6 Jan 2024 12:16:22 +0000 Subject: [PATCH] feat: update context menu (#187) * feat: update context meu * fix: context menu placement --- .changeset/sharp-cobras-hammer.md | 6 + apps/website/src/data/components-sidebar.ts | 15 +- .../components/overlay/context-menu/props.mdx | 10 ++ .../overlay/context-menu/theming.mdx | 10 ++ .../components/overlay/context-menu/usage.mdx | 50 ++++++ packages/saas-ui-core/package.json | 2 + .../saas-ui-core/src/menu/context-menu.tsx | 149 ++++++++++++++---- yarn.lock | 84 ++++++++++ 8 files changed, 282 insertions(+), 44 deletions(-) create mode 100644 .changeset/sharp-cobras-hammer.md create mode 100644 apps/website/src/pages/docs/components/overlay/context-menu/props.mdx create mode 100644 apps/website/src/pages/docs/components/overlay/context-menu/theming.mdx create mode 100644 apps/website/src/pages/docs/components/overlay/context-menu/usage.mdx diff --git a/.changeset/sharp-cobras-hammer.md b/.changeset/sharp-cobras-hammer.md new file mode 100644 index 000000000..7ca5fa86d --- /dev/null +++ b/.changeset/sharp-cobras-hammer.md @@ -0,0 +1,6 @@ +--- +'@saas-ui/core': patch +'@saas-ui/react': patch +--- + +Added long press support to ContextMenu diff --git a/apps/website/src/data/components-sidebar.ts b/apps/website/src/data/components-sidebar.ts index 17095e94e..3c6517de0 100644 --- a/apps/website/src/data/components-sidebar.ts +++ b/apps/website/src/data/components-sidebar.ts @@ -290,28 +290,23 @@ const sidebar = { }, { title: 'Overlay', - path: '/docs/overlay', + path: '/docs/components/overlay', heading: true, open: true, sort: false, routes: [ { - title: 'Modals manager', - path: '/docs/components/overlay/modals-manager', + title: 'ContextMenu', + path: '/docs/components/overlay/context-menu', }, { - title: 'MenuDialog', - path: '/docs/components/overlay/menu-dialog', + title: 'Modals manager', + path: '/docs/components/overlay/modals-manager', }, { title: 'FormDialog', path: '/docs/components/overlay/form-dialog', }, - { - title: 'ResponsiveMenu', - path: '/docs/components/overlay/responsive-menu', - pro: true, - }, ], }, { diff --git a/apps/website/src/pages/docs/components/overlay/context-menu/props.mdx b/apps/website/src/pages/docs/components/overlay/context-menu/props.mdx new file mode 100644 index 000000000..4f17f2467 --- /dev/null +++ b/apps/website/src/pages/docs/components/overlay/context-menu/props.mdx @@ -0,0 +1,10 @@ +--- +id: context-menu +scope: props +--- + +## Props + +### ContextMenu Props + + diff --git a/apps/website/src/pages/docs/components/overlay/context-menu/theming.mdx b/apps/website/src/pages/docs/components/overlay/context-menu/theming.mdx new file mode 100644 index 000000000..1f42668a4 --- /dev/null +++ b/apps/website/src/pages/docs/components/overlay/context-menu/theming.mdx @@ -0,0 +1,10 @@ +--- +id: context-menu +scope: theming +--- + +## Theming + +The `ContextMenu` composes the `Menu` component. + +- [Menu theming documentation](https://chakra-ui.com/docs/components/menu/theming) diff --git a/apps/website/src/pages/docs/components/overlay/context-menu/usage.mdx b/apps/website/src/pages/docs/components/overlay/context-menu/usage.mdx new file mode 100644 index 000000000..e9c342144 --- /dev/null +++ b/apps/website/src/pages/docs/components/overlay/context-menu/usage.mdx @@ -0,0 +1,50 @@ +--- +id: context-menu +title: Context Menu +description: A list of options that appears when a user interacts right-clicking on a trigger element. +--- + + + +## Import + +```ts +import { + ContextMenu, + ContextMenuTrigger, + ContextMenuList, + ContextMenuItem, +} from '@saas-ui/react' +``` + +## Usage + +```jsx inline=true +import { Center } from '@chakra-ui/react' +import { + ContextMenu, + ContextMenuTrigger, + ContextMenuList, + ContextMenuItem, +} from '@saas-ui/react' + +export default function Page() { + return ( + + +
+ Right click here +
+
+ + Edit + Copy + Delete + +
+ ) +} +``` diff --git a/packages/saas-ui-core/package.json b/packages/saas-ui-core/package.json index ad6ad07a3..35c81e205 100644 --- a/packages/saas-ui-core/package.json +++ b/packages/saas-ui-core/package.json @@ -87,9 +87,11 @@ "@chakra-ui/system": "^2.6.1", "@chakra-ui/theme-tools": "^2.1.1", "@chakra-ui/utils": "^2.0.15", + "@react-aria/interactions": "^3.20.1", "@react-aria/utils": "^3.22.0", "@saas-ui/react-utils": "workspace:*", "@saas-ui/theme": "workspace:*", + "@zag-js/dom-event": "^0.32.0", "@zag-js/dom-utils": "^0.2.4" }, "peerDependencies": { diff --git a/packages/saas-ui-core/src/menu/context-menu.tsx b/packages/saas-ui-core/src/menu/context-menu.tsx index f960fe6ab..a1287a90f 100644 --- a/packages/saas-ui-core/src/menu/context-menu.tsx +++ b/packages/saas-ui-core/src/menu/context-menu.tsx @@ -2,7 +2,6 @@ import * as React from 'react' import { useCallback, useRef, useState } from 'react' import { chakra, - Portal, Menu, MenuProps, MenuList, @@ -11,19 +10,27 @@ import { HTMLChakraProps, useMenuContext, useEventListener, + useOutsideClick, } from '@chakra-ui/react' import { createContext } from '@chakra-ui/react-utils' -import { runIfFn } from '@chakra-ui/utils' +import { AnyPointerEvent, callAllHandlers, runIfFn } from '@chakra-ui/utils' + +// @todo migrate this to Ark-ui ContextMenu +import { useLongPress } from '@react-aria/interactions' + +import { getEventPoint } from '@zag-js/dom-event' type Position = [number, number] +type Anchor = { x: number; y: number } export interface UseContextMenuReturn { isOpen: boolean - position: Position + anchor: Anchor triggerRef: React.RefObject + menuRef: React.RefObject onClose: () => void - onOpen: (event: React.MouseEvent) => void + onOpen: (event: AnyPointerEvent) => void } export const [ContextMenuProvider, useContextMenuContext] = @@ -37,23 +44,42 @@ export interface UseContextMenuProps extends ContextMenuProps { } export const useContextMenu = (props: UseContextMenuProps) => { + const { closeOnBlur = true } = props const [isOpen, setIsOpen] = useState(false) - const [position, setPosition] = useState([0, 0]) + const [anchor, setAnchor] = useState({ x: 0, y: 0 }) const triggerRef = useRef(null) + const menuRef = useRef(null) - // useOutsideClick off menu doesn't catch contextmenu + // useOutsideClick of menu doesn't catch contextmenu useEventListener('contextmenu', (e) => { if ( !triggerRef.current?.contains(e.target as any) && e.target !== triggerRef.current ) { setIsOpen(false) + } else { + e.preventDefault() + e.stopPropagation() } }) - const onOpen = useCallback((event: React.MouseEvent) => { + useOutsideClick({ + enabled: isOpen && closeOnBlur, + ref: menuRef, + handler: (event) => { + if ( + !triggerRef.current?.contains(event.target as HTMLElement) && + menuRef.current?.parentElement !== event.target + ) { + onClose() + } + }, + }) + + const onOpen = useCallback((event: AnyPointerEvent) => { + const point = getEventPoint(event) + setAnchor(point) setIsOpen(true) - setPosition([event.pageX, event.pageY]) }, []) const onClose = useCallback(() => { @@ -63,8 +89,9 @@ export const useContextMenu = (props: UseContextMenuProps) => { return { isOpen, - position, + anchor, triggerRef, + menuRef, onClose, onOpen, } @@ -80,7 +107,13 @@ export const ContextMenu: React.FC = (props) => { const { isOpen, onClose } = context return ( - + {(fnProps) => ( {runIfFn(children, fnProps)} @@ -92,29 +125,86 @@ export const ContextMenu: React.FC = (props) => { ContextMenu.displayName = 'ContextMenu' +const generateClientRect = (x = 0, y = 0) => { + return () => { + return { + width: 0, + height: 0, + top: y, + left: x, + right: x, + bottom: y, + } + } +} + +const useContextMenuTrigger = (props: ContextMenuTriggerProps) => { + const { triggerRef, onOpen, onClose, anchor } = useContextMenuContext() + + const menu = useMenuContext() + + const { popper, openAndFocusFirstItem } = menu + + const { longPressProps } = useLongPress({ + accessibilityDescription: 'Long press to open context menu', + onLongPressStart: (e) => { + if (e.pointerType === 'mouse') { + onClose() + } + }, + onLongPress: (e) => { + if (e.pointerType === 'mouse') return + + if (e.type === 'longpress') { + onOpen(e as unknown as AnyPointerEvent) + openAndFocusFirstItem() + } + }, + }) + + const anchorRef = React.useRef({ + getBoundingClientRect: generateClientRect(anchor.x, anchor.y), + }) + + React.useEffect(() => { + popper.referenceRef(anchorRef.current) + }, []) + + React.useEffect(() => { + anchorRef.current.getBoundingClientRect = generateClientRect( + anchor.x, + anchor.y + ) + menu.popper.update() + }, [anchor]) + + return { + triggerProps: { + ...longPressProps, + onContextMenu: callAllHandlers((event: AnyPointerEvent) => { + event.preventDefault() + onOpen(event) + openAndFocusFirstItem() + }, props.onContextMenu as any), + ref: triggerRef, + }, + } +} + export interface ContextMenuTriggerProps extends HTMLChakraProps<'span'> {} export const ContextMenuTrigger: React.FC = ( props ) => { const { children, ...rest } = props - const { triggerRef, onOpen } = useContextMenuContext() - - const menu = useMenuContext() - const { openAndFocusFirstItem } = menu + const { triggerProps } = useContextMenuTrigger(props) - // @todo add long press support return ( { - event.preventDefault() - onOpen(event) - openAndFocusFirstItem() - }} - ref={triggerRef} + {...triggerProps} > {children} @@ -127,21 +217,12 @@ export interface ContextMenuListProps extends MenuListProps {} export const ContextMenuList: React.FC = (props) => { const { children, ...rest } = props - const { position } = useContextMenuContext() + const { menuRef } = useContextMenuContext() return ( - - - {children} - - + + {children} + ) } diff --git a/yarn.lock b/yarn.lock index a3fb94bbc..7bb41f5f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7769,6 +7769,20 @@ __metadata: languageName: node linkType: hard +"@react-aria/interactions@npm:^3.20.1": + version: 3.20.1 + resolution: "@react-aria/interactions@npm:3.20.1" + dependencies: + "@react-aria/ssr": "npm:^3.9.1" + "@react-aria/utils": "npm:^3.23.0" + "@react-types/shared": "npm:^3.22.0" + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: 17fbbab6bfb0e0860fa0250ba644b1e6aaf23023e143a7f9f816c37d164813f858be592644cc89f06f6a1baa9dffdfa8bd5a2db12246f0837dce4a9169217932 + languageName: node + linkType: hard + "@react-aria/label@npm:^3.7.3": version: 3.7.3 resolution: "@react-aria/label@npm:3.7.3" @@ -7819,6 +7833,17 @@ __metadata: languageName: node linkType: hard +"@react-aria/ssr@npm:^3.9.1": + version: 3.9.1 + resolution: "@react-aria/ssr@npm:3.9.1" + dependencies: + "@swc/helpers": "npm:^0.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: a42bf23241b022e2e55ca95aeec5cafb3aa276b4586373f4b85834655ab05068d5af81707bf1d4548f2f5b29c80a02ef920c0711b2d1a8b189effca2c72ca5f9 + languageName: node + linkType: hard + "@react-aria/utils@npm:^3.22.0": version: 3.22.0 resolution: "@react-aria/utils@npm:3.22.0" @@ -7834,6 +7859,21 @@ __metadata: languageName: node linkType: hard +"@react-aria/utils@npm:^3.23.0": + version: 3.23.0 + resolution: "@react-aria/utils@npm:3.23.0" + dependencies: + "@react-aria/ssr": "npm:^3.9.1" + "@react-stately/utils": "npm:^3.9.0" + "@react-types/shared": "npm:^3.22.0" + "@swc/helpers": "npm:^0.5.0" + clsx: "npm:^2.0.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 + checksum: 7be5f852fb80b4cdb0a983542804534ce14bbd3809c8e81786507335d457202a5cd57a4a437c32aabb1b678902405da00f5ba9d697c7ab6f33cf0840bb1978be + languageName: node + linkType: hard + "@react-hookz/deep-equal@npm:^1.0.4": version: 1.0.4 resolution: "@react-hookz/deep-equal@npm:1.0.4" @@ -8254,9 +8294,11 @@ __metadata: "@chakra-ui/system": "npm:^2.6.1" "@chakra-ui/theme-tools": "npm:^2.1.1" "@chakra-ui/utils": "npm:^2.0.15" + "@react-aria/interactions": "npm:^3.20.1" "@react-aria/utils": "npm:^3.22.0" "@saas-ui/react-utils": "workspace:*" "@saas-ui/theme": "workspace:*" + "@zag-js/dom-event": "npm:^0.32.0" "@zag-js/dom-utils": "npm:^0.2.4" tsup: "npm:^6.7.0" peerDependencies: @@ -12531,6 +12573,16 @@ __metadata: languageName: node linkType: hard +"@zag-js/dom-event@npm:^0.32.0": + version: 0.32.0 + resolution: "@zag-js/dom-event@npm:0.32.0" + dependencies: + "@zag-js/text-selection": "npm:0.32.0" + "@zag-js/types": "npm:0.32.0" + checksum: c7461c603a0404bb08d533fb33cabef9479b0fe818f80b5524db5684750c7a9f5e0122ae1a2fd938f43526eedfd7cc9bd8d88a9913b7976d4c30383fd26c32d1 + languageName: node + linkType: hard + "@zag-js/dom-query@npm:0.16.0": version: 0.16.0 resolution: "@zag-js/dom-query@npm:0.16.0" @@ -12552,6 +12604,13 @@ __metadata: languageName: node linkType: hard +"@zag-js/dom-query@npm:0.32.0": + version: 0.32.0 + resolution: "@zag-js/dom-query@npm:0.32.0" + checksum: 2e4397cd1e79a16bf3aaa7d9a634d89096518063732210b86604ad287f3846e3b3620eb0e54924cbf052c51e2f375a010ec85025896ad3407ebb522e6cdb11b5 + languageName: node + linkType: hard + "@zag-js/dom-utils@npm:^0.2.4": version: 0.2.4 resolution: "@zag-js/dom-utils@npm:0.2.4" @@ -13029,6 +13088,15 @@ __metadata: languageName: node linkType: hard +"@zag-js/text-selection@npm:0.32.0": + version: 0.32.0 + resolution: "@zag-js/text-selection@npm:0.32.0" + dependencies: + "@zag-js/dom-query": "npm:0.32.0" + checksum: 1489c8ac6eb218ebb4881c65a902983c4591e879ce5896c27838408d65d57862ed68a12e385c5050d79a9328072017a224943bf02ad176a3fe4b53eded1681f2 + languageName: node + linkType: hard + "@zag-js/toast@npm:0.30.0": version: 0.30.0 resolution: "@zag-js/toast@npm:0.30.0" @@ -13099,6 +13167,15 @@ __metadata: languageName: node linkType: hard +"@zag-js/types@npm:0.32.0": + version: 0.32.0 + resolution: "@zag-js/types@npm:0.32.0" + dependencies: + csstype: "npm:3.1.2" + checksum: defcb20a4bc2b34f0696521d5fd1d082330743946f71f637a80a297a5ed359879aab1875255d5141e03c2533fc869c248c88a92ae295a17dacfe751cccf85d08 + languageName: node + linkType: hard + "@zag-js/utils@npm:0.23.0": version: 0.23.0 resolution: "@zag-js/utils@npm:0.23.0" @@ -15022,6 +15099,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^2.0.0": + version: 2.1.0 + resolution: "clsx@npm:2.1.0" + checksum: 2e0ce7c3b6803d74fc8147c408f88e79245583202ac14abd9691e2aebb9f312de44270b79154320d10bb7804a9197869635d1291741084826cff20820f31542b + languageName: node + linkType: hard + "cluster-key-slot@npm:^1.1.0": version: 1.1.2 resolution: "cluster-key-slot@npm:1.1.2"