Skip to content

Commit

Permalink
Merge ebf5274 into cb54f42
Browse files Browse the repository at this point in the history
  • Loading branch information
iansan5653 committed Mar 13, 2024
2 parents cb54f42 + ebf5274 commit cedd9f8
Show file tree
Hide file tree
Showing 5 changed files with 326 additions and 21 deletions.
18 changes: 17 additions & 1 deletion packages/react/src/ActionMenu/ActionMenu.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"type": "React.ReactElement[]",
"defaultValue": "",
"required": true,
"description": "Recommended: `ActionMenu.Button` or `ActionMenu.Anchor` with `ActionMenu.Overlay`"
"description": "Recommended: `ActionMenu.Button`, `ActionMenu.MenuItemAnchor`, or `ActionMenu.Anchor`, with `ActionMenu.Overlay`"
},
{
"name": "open",
Expand Down Expand Up @@ -48,6 +48,22 @@
"url": "/react/Button"
}
},
{
"name": "ActionMenu.MenuItemAnchor",
"props": [
{
"name": "children",
"type": "React.ReactElement",
"defaultValue": "",
"required": true,
"description": ""
}
],
"passthrough": {
"element": "ActionList.Item",
"url": "/react/ActionList"
}
},
{
"name": "ActionMenu.Anchor",
"props": [
Expand Down
33 changes: 33 additions & 0 deletions packages/react/src/ActionMenu/ActionMenu.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,36 @@ export const InactiveItems = () => (
</ActionMenu.Overlay>
</ActionMenu>
)

export const Submenus = () => (
<ActionMenu>
<ActionMenu.Button>Edit</ActionMenu.Button>
<ActionMenu.Overlay>
<ActionList>
<ActionList.Item>Cut</ActionList.Item>
<ActionList.Item>Copy</ActionList.Item>
<ActionList.Item>Paste</ActionList.Item>
<ActionMenu>
<ActionMenu.MenuItemAnchor>Paste special</ActionMenu.MenuItemAnchor>
<ActionMenu.Overlay>
<ActionList>
<ActionList.Item>Paste plain text</ActionList.Item>
<ActionList.Item>Paste formulas</ActionList.Item>
<ActionList.Item>Paste with formatting</ActionList.Item>
<ActionMenu>
<ActionMenu.MenuItemAnchor>Paste from</ActionMenu.MenuItemAnchor>
<ActionMenu.Overlay>
<ActionList>
<ActionList.Item>Current clipboard</ActionList.Item>
<ActionList.Item>History</ActionList.Item>
<ActionList.Item>Another device</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
)
100 changes: 84 additions & 16 deletions packages/react/src/ActionMenu/ActionMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import React from 'react'
import {TriangleDownIcon} from '@primer/octicons-react'
import React, {useContext} from 'react'
import {TriangleDownIcon, ChevronRightIcon} from '@primer/octicons-react'
import type {AnchoredOverlayProps} from '../AnchoredOverlay'
import {AnchoredOverlay} from '../AnchoredOverlay'
import type {OverlayProps} from '../Overlay'
import {useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuKeyboardNavigation} from '../hooks'
import {
useProvidedRefOrCreate,
useProvidedStateOrCreate,
useMenuKeyboardNavigation,
useRefObjectAsForwardedRef,
} from '../hooks'
import {Divider} from '../ActionList/Divider'
import {ActionListContainerContext} from '../ActionList/ActionListContainerContext'
import type {ButtonProps} from '../Button'
Expand All @@ -12,12 +17,18 @@ import {useId} from '../hooks/useId'
import type {MandateProps} from '../utils/types'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import {Tooltip} from '../TooltipV2/Tooltip'
import {ActionList, type ActionListItemProps} from '../ActionList'

export type MenuCloseHandler = (
gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab' | 'item-select' | 'arrow-left',
) => void

export type MenuContextProps = Pick<
AnchoredOverlayProps,
'anchorRef' | 'renderAnchor' | 'open' | 'onOpen' | 'anchorId'
> & {
onClose?: (gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab') => void
onClose?: MenuCloseHandler
isSubmenu?: boolean
}
const MenuContext = React.createContext<MenuContextProps>({renderAnchor: null, open: false})

Expand All @@ -44,9 +55,23 @@ const Menu: React.FC<React.PropsWithChildren<ActionMenuProps>> = ({
onOpenChange,
children,
}: ActionMenuProps) => {
const parentMenuContext = useContext(MenuContext)

const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, onOpenChange, false)
const onOpen = React.useCallback(() => setCombinedOpenState(true), [setCombinedOpenState])
const onClose = React.useCallback(() => setCombinedOpenState(false), [setCombinedOpenState])
const onClose: MenuCloseHandler = React.useCallback(
gesture => {
setCombinedOpenState(false)

// Close the parent stack when an item is selected or the user tabs out of the menu entirely
switch (gesture) {
case 'tab':
case 'item-select':
parentMenuContext.onClose?.(gesture)
}
},
[setCombinedOpenState, parentMenuContext],
)

const menuButtonChild = React.Children.toArray(children).find(
child => React.isValidElement<ActionMenuButtonProps>(child) && (child.type === MenuButton || child.type === Anchor),
Expand Down Expand Up @@ -91,7 +116,7 @@ const Menu: React.FC<React.PropsWithChildren<ActionMenuProps>> = ({
renderAnchor = anchorProps => React.cloneElement(child, anchorProps)
}
return null
} else if (child.type === MenuButton) {
} else if (child.type === MenuButton || child.type === MenuItemAnchor) {
renderAnchor = anchorProps => React.cloneElement(child, anchorProps)
return null
} else {
Expand All @@ -100,7 +125,18 @@ const Menu: React.FC<React.PropsWithChildren<ActionMenuProps>> = ({
})

return (
<MenuContext.Provider value={{anchorRef, renderAnchor, anchorId, open: combinedOpenState, onOpen, onClose}}>
<MenuContext.Provider
value={{
anchorRef,
renderAnchor,
anchorId,
open: combinedOpenState,
onOpen,
onClose,
// will be undefined for the outermost level, then false for the top menu, then true inside that
isSubmenu: parentMenuContext.isSubmenu !== undefined,
}}
>
{contents}
</MenuContext.Provider>
)
Expand All @@ -123,6 +159,33 @@ const MenuButton = React.forwardRef(({...props}, anchorRef) => {
)
}) as PolymorphicForwardRefComponent<'button', ActionMenuButtonProps>

export type MenuItemAnchorProps = ActionListItemProps
const MenuItemAnchor = React.forwardRef(({children, onKeyDown: externalOnKeyDown, ...props}, forwardedRef) => {
const anchorRef = React.useRef<HTMLLIElement>(null)
useRefObjectAsForwardedRef(forwardedRef, anchorRef)

const {onOpen} = React.useContext(MenuContext)

/** Treat right arrow key press as click. */
const onKeyDown: React.KeyboardEventHandler<HTMLLIElement> = event => {
externalOnKeyDown?.(event)
if (event.key === 'ArrowRight' && !event.defaultPrevented) onOpen?.('anchor-key-press')
}

return (
<Anchor ref={anchorRef}>
<ActionList.Item {...props} onKeyDown={onKeyDown}>
{/* Slots will grab the first TrailingVisual encountered. so by putting children first we allow the consumer
to override the chevron icon. */}
{children}
<ActionList.TrailingVisual>
<ChevronRightIcon />
</ActionList.TrailingVisual>
</ActionList.Item>
</Anchor>
)
}) as PolymorphicForwardRefComponent<'li', MenuItemAnchorProps>

type MenuOverlayProps = Partial<OverlayProps> &
Pick<AnchoredOverlayProps, 'align' | 'side'> & {
/**
Expand All @@ -133,19 +196,24 @@ type MenuOverlayProps = Partial<OverlayProps> &
const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
children,
align = 'start',
side = 'outside-bottom',
side,
'aria-labelledby': ariaLabelledby,
...overlayProps
}) => {
// we typecast anchorRef as required instead of optional
// because we know that we're setting it in context in Menu
const {anchorRef, renderAnchor, anchorId, open, onOpen, onClose} = React.useContext(MenuContext) as MandateProps<
MenuContextProps,
'anchorRef'
>
const {
anchorRef,
renderAnchor,
anchorId,
open,
onOpen,
onClose,
isSubmenu = false,
} = React.useContext(MenuContext) as MandateProps<MenuContextProps, 'anchorRef'>

const containerRef = React.useRef<HTMLDivElement>(null)
useMenuKeyboardNavigation(open, onClose, containerRef, anchorRef)
useMenuKeyboardNavigation(open, onClose, containerRef, anchorRef, isSubmenu)

return (
<AnchoredOverlay
Expand All @@ -156,7 +224,7 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
onOpen={onOpen}
onClose={onClose}
align={align}
side={side}
side={side ?? (isSubmenu ? 'outside-right' : 'outside-bottom')}
overlayProps={overlayProps}
focusZoneSettings={{focusOutBehavior: 'wrap'}}
>
Expand All @@ -167,7 +235,7 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
listRole: 'menu',
listLabelledBy: ariaLabelledby || anchorId,
selectionAttribute: 'aria-checked', // Should this be here?
afterSelect: onClose,
afterSelect: () => onClose?.('item-select'),
}}
>
{children}
Expand All @@ -178,4 +246,4 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
}

Menu.displayName = 'ActionMenu'
export const ActionMenu = Object.assign(Menu, {Button: MenuButton, Anchor, Overlay, Divider})
export const ActionMenu = Object.assign(Menu, {Button: MenuButton, MenuItemAnchor, Anchor, Overlay, Divider})
Loading

0 comments on commit cedd9f8

Please sign in to comment.