Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revert "Revert "Add support for nested submenus to ActionMenu"" #4486

Merged
merged 8 commits into from
May 7, 2024
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 @@ -339,7 +347,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, {useEffect, useState} from 'react'
import {TriangleDownIcon} from '@primer/octicons-react'
import React, {useCallback, useContext, useMemo, useEffect, useState} 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)

// If the menu anchor is an icon button, we need to label the menu by tooltip that also labelled the anchor.
const [anchorAriaLabelledby, setAnchorAriaLabelledby] = useState<null | string>(null)
Expand All @@ -167,7 +235,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 @@ -179,7 +247,7 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
// If there is a custom aria-labelledby, use that. Otherwise, if exists, use the id that labels the anchor such as tooltip. If none of them exist, use anchor id.
listLabelledBy: ariaLabelledby || anchorAriaLabelledby || anchorId,
selectionAttribute: 'aria-checked', // Should this be here?
afterSelect: onClose,
afterSelect: () => onClose?.('item-select'),
}}
>
{children}
Expand Down
Loading
Loading