Skip to content

Commit

Permalink
feat: update context menu (#187)
Browse files Browse the repository at this point in the history
* feat: update context meu

* fix: context menu placement
  • Loading branch information
Pagebakers authored Jan 6, 2024
1 parent 88780af commit 781a1c2
Show file tree
Hide file tree
Showing 8 changed files with 282 additions and 44 deletions.
6 changes: 6 additions & 0 deletions .changeset/sharp-cobras-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@saas-ui/core': patch
'@saas-ui/react': patch
---

Added long press support to ContextMenu
15 changes: 5 additions & 10 deletions apps/website/src/data/components-sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
],
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
id: context-menu
scope: props
---

## Props

### ContextMenu Props

<PropsTable of="ContextMenu" />
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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.
---

<ComponentLinks
github={{ package: 'saas-ui-core' }}
npm={{ package: '@saas-ui/core' }}
/>

## 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 (
<ContextMenu>
<ContextMenuTrigger>
<Center height="200px" borderWidth="1px">
Right click here
</Center>
</ContextMenuTrigger>
<ContextMenuList>
<ContextMenuItem>Edit</ContextMenuItem>
<ContextMenuItem>Copy</ContextMenuItem>
<ContextMenuItem>Delete</ContextMenuItem>
</ContextMenuList>
</ContextMenu>
)
}
```
2 changes: 2 additions & 0 deletions packages/saas-ui-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
149 changes: 115 additions & 34 deletions packages/saas-ui-core/src/menu/context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import {
chakra,
Portal,
Menu,
MenuProps,
MenuList,
Expand All @@ -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<HTMLSpanElement>
menuRef: React.RefObject<HTMLDivElement>
onClose: () => void
onOpen: (event: React.MouseEvent) => void
onOpen: (event: AnyPointerEvent) => void
}

export const [ContextMenuProvider, useContextMenuContext] =
Expand All @@ -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<Position>([0, 0])
const [anchor, setAnchor] = useState<Anchor>({ x: 0, y: 0 })
const triggerRef = useRef<HTMLSpanElement>(null)
const menuRef = useRef<HTMLDivElement>(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(() => {
Expand All @@ -63,8 +89,9 @@ export const useContextMenu = (props: UseContextMenuProps) => {

return {
isOpen,
position,
anchor,
triggerRef,
menuRef,
onClose,
onOpen,
}
Expand All @@ -80,7 +107,13 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
const { isOpen, onClose } = context

return (
<Menu gutter={0} {...rest} isOpen={isOpen} onClose={onClose}>
<Menu
gutter={0}
{...rest}
isOpen={isOpen}
onClose={onClose}
closeOnBlur={false}
>
{(fnProps) => (
<ContextMenuProvider value={context}>
{runIfFn(children, fnProps)}
Expand All @@ -92,29 +125,86 @@ export const ContextMenu: React.FC<ContextMenuProps> = (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<ContextMenuTriggerProps> = (
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 (
<chakra.span
{...rest}
sx={{ WebkitTouchCallout: 'none' }}
onContextMenu={(event) => {
event.preventDefault()
onOpen(event)
openAndFocusFirstItem()
}}
ref={triggerRef}
{...triggerProps}
>
{children}
</chakra.span>
Expand All @@ -127,21 +217,12 @@ export interface ContextMenuListProps extends MenuListProps {}

export const ContextMenuList: React.FC<ContextMenuListProps> = (props) => {
const { children, ...rest } = props
const { position } = useContextMenuContext()
const { menuRef } = useContextMenuContext()

return (
<Portal>
<MenuList
{...rest}
style={{
position: 'absolute',
left: position[0],
top: position[1],
}}
>
{children}
</MenuList>
</Portal>
<MenuList ref={menuRef} {...rest}>
{children}
</MenuList>
)
}

Expand Down
Loading

1 comment on commit 781a1c2

@vercel
Copy link

@vercel vercel bot commented on 781a1c2 Jan 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.