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

Add support for nested submenus to ActionMenu #4386

Merged
merged 22 commits into from
Mar 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b7fd427
Close parent stack when an item is picked
iansan5653 Mar 13, 2024
8f9ccf9
Add `MenuItemAnchor` component
iansan5653 Mar 13, 2024
32f58b6
Close submenus when left arrow is pressed
iansan5653 Mar 13, 2024
36cd9c7
Align submenus to the right of anchors
iansan5653 Mar 13, 2024
11ba250
Add story
iansan5653 Mar 13, 2024
4b588ec
Fix open on right arrow key
iansan5653 Mar 13, 2024
0ccdd5e
Add unit tests
iansan5653 Mar 13, 2024
ebf5274
Move story and improve
iansan5653 Mar 13, 2024
d725121
Create actionmenu-submenus.md
iansan5653 Mar 13, 2024
e8223b1
Update .changeset/wild-students-bow.md
iansan5653 Mar 19, 2024
d0c3710
Refactor
iansan5653 Mar 21, 2024
c4ba534
Merge branch 'main' of https://github.com/primer/react into action-me…
iansan5653 Mar 21, 2024
fb103fb
Replace `MenuItemAnchor` with auto wiring for `ActionList.Item`
iansan5653 Mar 22, 2024
072f249
Use context instead of manipulating children
iansan5653 Mar 25, 2024
17d1848
Merge branch 'main' of https://github.com/primer/react into action-me…
iansan5653 Mar 25, 2024
1c81b72
Update packages/react/src/ActionMenu/ActionMenu.docs.json
iansan5653 Mar 27, 2024
5ac52c8
Add wrapped component to storybook example
iansan5653 Mar 27, 2024
6a00e67
Fix changeset
iansan5653 Mar 27, 2024
8f185ab
Fix default trailing visual styling
iansan5653 Mar 27, 2024
98d8a11
Revert "Add wrapped component to storybook example"
iansan5653 Mar 27, 2024
39568d4
Don't render the trailing visual wrapper unless there actually is a d…
iansan5653 Mar 28, 2024
d207bf7
Fix submenu anchor closing menu on `Enter` press
iansan5653 Mar 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
Copy link
Collaborator

Choose a reason for hiding this comment

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

🥳

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
Loading