Skip to content

Commit

Permalink
Add support for nested submenus to ActionMenu (#4386)
Browse files Browse the repository at this point in the history
* Close parent stack when an item is picked

* Add `MenuItemAnchor` component

* Close submenus when left arrow is pressed

* Align submenus to the right of anchors

* Add story

* Fix open on right arrow key

* Add unit tests

* Move story and improve

* Create actionmenu-submenus.md

* Update .changeset/wild-students-bow.md

* Refactor

* Replace `MenuItemAnchor` with auto wiring for `ActionList.Item`

* Use context instead of manipulating children

* Update packages/react/src/ActionMenu/ActionMenu.docs.json

Co-authored-by: Armağan <broccolinisoup@github.com>

* Add wrapped component to storybook example

* Fix changeset

* Fix default trailing visual styling

* Revert "Add wrapped component to storybook example"

This reverts commit 5ac52c8.

* Don't render the trailing visual wrapper unless there actually is a default

* Fix submenu anchor closing menu on `Enter` press

---------

Co-authored-by: Armağan <broccolinisoup@github.com>
  • Loading branch information
iansan5653 and broccolinisoup authored Mar 28, 2024
1 parent a3355a5 commit 4e281b2
Show file tree
Hide file tree
Showing 7 changed files with 347 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/wild-students-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": minor
---

Adds support for nested submenus to `ActionMenu`
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type ContextProps = {
// eslint-disable-next-line @typescript-eslint/ban-types
afterSelect?: Function
enableFocusZone?: boolean
defaultTrailingVisual?: React.ReactElement
}

export const ActionListContainerContext = React.createContext<ContextProps>({})
14 changes: 11 additions & 3 deletions packages/react/src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,22 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
inlineDescription: [Description, props => props.variant !== 'block'],
})

const {container, afterSelect, selectionAttribute, defaultTrailingVisual} =
React.useContext(ActionListContainerContext)

// Be sure to avoid rendering the container unless there is a default
const wrappedDefaultTrailingVisual = defaultTrailingVisual ? (
<TrailingVisual>{defaultTrailingVisual}</TrailingVisual>
) : null
const trailingVisual = slots.trailingVisual ?? wrappedDefaultTrailingVisual

const {
variant: listVariant,
role: listRole,
showDividers,
selectionVariant: listSelectionVariant,
} = React.useContext(ListContext)
const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext)
const {container, afterSelect, selectionAttribute} = React.useContext(ActionListContainerContext)
const inactive = Boolean(inactiveText)
const showInactiveIndicator = inactive && container === undefined

Expand Down Expand Up @@ -308,7 +316,7 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
sx={{display: 'flex', flexDirection: 'column', flexGrow: 1, minWidth: 0}}
>
<ConditionalWrapper
if={Boolean(slots.trailingVisual) || (showInactiveIndicator && !slots.leadingVisual)}
if={Boolean(trailingVisual) || (showInactiveIndicator && !slots.leadingVisual)}
sx={{display: 'flex', flexGrow: 1}}
>
<ConditionalWrapper
Expand Down Expand Up @@ -338,7 +346,7 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
) : (
// If it's not inactive, or it has a leading visual that can be replaced,
// just render the trailing visual slot.
slots.trailingVisual
trailingVisual
)
}
</ConditionalWrapper>
Expand Down
53 changes: 52 additions & 1 deletion packages/react/src/ActionMenu/ActionMenu.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import React from 'react'
import {ActionMenu, ActionList, Box} from '../'
import {WorkflowIcon, ArchiveIcon, GearIcon, CopyIcon, RocketIcon, CommentIcon, BookIcon} from '@primer/octicons-react'
import {
WorkflowIcon,
ArchiveIcon,
GearIcon,
CopyIcon,
RocketIcon,
CommentIcon,
BookIcon,
SparkleFillIcon,
} from '@primer/octicons-react'

export default {
title: 'Components/ActionMenu/Features',
Expand Down Expand Up @@ -181,3 +190,45 @@ 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.Anchor>
<ActionList.Item>
<ActionList.LeadingVisual>
<SparkleFillIcon />
</ActionList.LeadingVisual>
Paste special
</ActionList.Item>
</ActionMenu.Anchor>
<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.Anchor>
<ActionList.Item>Paste from</ActionList.Item>
</ActionMenu.Anchor>
<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>
)
96 changes: 82 additions & 14 deletions packages/react/src/ActionMenu/ActionMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react'
import {TriangleDownIcon} from '@primer/octicons-react'
import React, {useCallback, useContext, useMemo} from 'react'
import {TriangleDownIcon, ChevronRightIcon} from '@primer/octicons-react'
import type {AnchoredOverlayProps} from '../AnchoredOverlay'
import {AnchoredOverlay} from '../AnchoredOverlay'
import type {OverlayProps} from '../Overlay'
Expand All @@ -13,11 +13,16 @@ import type {MandateProps} from '../utils/types'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import {Tooltip} from '../TooltipV2/Tooltip'

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 +49,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 @@ -100,15 +119,59 @@ 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>
)
}

export type ActionMenuAnchorProps = {children: React.ReactElement; id?: string}
const Anchor = React.forwardRef<HTMLElement, ActionMenuAnchorProps>(({children, ...anchorProps}, anchorRef) => {
return React.cloneElement(children, {...anchorProps, ref: anchorRef})
const {onOpen, isSubmenu} = React.useContext(MenuContext)

const openSubmenuOnRightArrow: React.KeyboardEventHandler<HTMLElement> = useCallback(
event => {
children.props.onKeyDown?.(event)
if (isSubmenu && event.key === 'ArrowRight' && !event.defaultPrevented) onOpen?.('anchor-key-press')
},
[children, isSubmenu, onOpen],
)

// Add right chevron icon to submenu anchors rendered using `ActionList.Item`
const parentActionListContext = useContext(ActionListContainerContext)
const thisActionListContext = useMemo(
() =>
isSubmenu
? {
...parentActionListContext,
defaultTrailingVisual: <ChevronRightIcon />,
// Default behavior is to close after selecting; we want to open the submenu instead
afterSelect: () => onOpen?.('anchor-click'),
}
: parentActionListContext,
[isSubmenu, onOpen, parentActionListContext],
)

return (
<ActionListContainerContext.Provider value={thisActionListContext}>
{React.cloneElement(children, {
...anchorProps,
ref: anchorRef,
onKeyDown: openSubmenuOnRightArrow,
})}
</ActionListContainerContext.Provider>
)
})

/** this component is syntactical sugar 🍭 */
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 Down
Loading

0 comments on commit 4e281b2

Please sign in to comment.