Skip to content

Commit c48ffd2

Browse files
iansan5653siddharthkppksjce
authored andcommitted
Revert "Revert "Add support for nested submenus to ActionMenu"" (#4486)
* Revert "Revert "Add support for nested submenus to `ActionMenu` (#4386)" (#4472)" This reverts commit 82072eb. * just want a change to trigger rebuild --------- Co-authored-by: Siddharth Kshetrapal <[email protected]> Co-authored-by: Pavithra Kodmad <[email protected]>
1 parent cc0d8e4 commit c48ffd2

File tree

7 files changed

+348
-22
lines changed

7 files changed

+348
-22
lines changed

.changeset/wild-students-bow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": minor
3+
---
4+
5+
Adds support for nested submenus to `ActionMenu`

packages/react/src/ActionList/ActionListContainerContext.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type ContextProps = {
1414
// eslint-disable-next-line @typescript-eslint/ban-types
1515
afterSelect?: Function
1616
enableFocusZone?: boolean
17+
defaultTrailingVisual?: React.ReactElement
1718
}
1819

1920
export const ActionListContainerContext = React.createContext<ContextProps>({})

packages/react/src/ActionList/Item.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,22 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
7474
inlineDescription: [Description, props => props.variant !== 'block'],
7575
})
7676

77+
const {container, afterSelect, selectionAttribute, defaultTrailingVisual} =
78+
React.useContext(ActionListContainerContext)
79+
80+
// Be sure to avoid rendering the container unless there is a default
81+
const wrappedDefaultTrailingVisual = defaultTrailingVisual ? (
82+
<TrailingVisual>{defaultTrailingVisual}</TrailingVisual>
83+
) : null
84+
const trailingVisual = slots.trailingVisual ?? wrappedDefaultTrailingVisual
85+
7786
const {
7887
variant: listVariant,
7988
role: listRole,
8089
showDividers,
8190
selectionVariant: listSelectionVariant,
8291
} = React.useContext(ListContext)
8392
const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext)
84-
const {container, afterSelect, selectionAttribute} = React.useContext(ActionListContainerContext)
8593
const inactive = Boolean(inactiveText)
8694
const showInactiveIndicator = inactive && container === undefined
8795

@@ -308,7 +316,7 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
308316
sx={{display: 'flex', flexDirection: 'column', flexGrow: 1, minWidth: 0}}
309317
>
310318
<ConditionalWrapper
311-
if={Boolean(slots.trailingVisual) || (showInactiveIndicator && !slots.leadingVisual)}
319+
if={Boolean(trailingVisual) || (showInactiveIndicator && !slots.leadingVisual)}
312320
sx={{display: 'flex', flexGrow: 1}}
313321
>
314322
<ConditionalWrapper
@@ -339,7 +347,7 @@ export const Item = React.forwardRef<HTMLLIElement, ActionListItemProps>(
339347
) : (
340348
// If it's not inactive, or it has a leading visual that can be replaced,
341349
// just render the trailing visual slot.
342-
slots.trailingVisual
350+
trailingVisual
343351
)
344352
}
345353
</ConditionalWrapper>

packages/react/src/ActionMenu/ActionMenu.features.stories.tsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import React from 'react'
22
import {ActionMenu, ActionList, Box} from '../'
3-
import {WorkflowIcon, ArchiveIcon, GearIcon, CopyIcon, RocketIcon, CommentIcon, BookIcon} from '@primer/octicons-react'
3+
import {
4+
WorkflowIcon,
5+
ArchiveIcon,
6+
GearIcon,
7+
CopyIcon,
8+
RocketIcon,
9+
CommentIcon,
10+
BookIcon,
11+
SparkleFillIcon,
12+
} from '@primer/octicons-react'
413

514
export default {
615
title: 'Components/ActionMenu/Features',
@@ -181,3 +190,45 @@ export const InactiveItems = () => (
181190
</ActionMenu.Overlay>
182191
</ActionMenu>
183192
)
193+
194+
export const Submenus = () => (
195+
<ActionMenu>
196+
<ActionMenu.Button>Edit</ActionMenu.Button>
197+
<ActionMenu.Overlay>
198+
<ActionList>
199+
<ActionList.Item>Cut</ActionList.Item>
200+
<ActionList.Item>Copy</ActionList.Item>
201+
<ActionList.Item>Paste</ActionList.Item>
202+
<ActionMenu>
203+
<ActionMenu.Anchor>
204+
<ActionList.Item>
205+
<ActionList.LeadingVisual>
206+
<SparkleFillIcon />
207+
</ActionList.LeadingVisual>
208+
Paste special
209+
</ActionList.Item>
210+
</ActionMenu.Anchor>
211+
<ActionMenu.Overlay>
212+
<ActionList>
213+
<ActionList.Item>Paste plain text</ActionList.Item>
214+
<ActionList.Item>Paste formulas</ActionList.Item>
215+
<ActionList.Item>Paste with formatting</ActionList.Item>
216+
<ActionMenu>
217+
<ActionMenu.Anchor>
218+
<ActionList.Item>Paste from</ActionList.Item>
219+
</ActionMenu.Anchor>
220+
<ActionMenu.Overlay>
221+
<ActionList>
222+
<ActionList.Item>Current clipboard</ActionList.Item>
223+
<ActionList.Item>History</ActionList.Item>
224+
<ActionList.Item>Another device</ActionList.Item>
225+
</ActionList>
226+
</ActionMenu.Overlay>
227+
</ActionMenu>
228+
</ActionList>
229+
</ActionMenu.Overlay>
230+
</ActionMenu>
231+
</ActionList>
232+
</ActionMenu.Overlay>
233+
</ActionMenu>
234+
)

packages/react/src/ActionMenu/ActionMenu.tsx

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import React, {useEffect, useState} from 'react'
2-
import {TriangleDownIcon} from '@primer/octicons-react'
1+
import React, {useCallback, useContext, useMemo, useEffect, useState} from 'react'
2+
import {TriangleDownIcon, ChevronRightIcon} from '@primer/octicons-react'
33
import type {AnchoredOverlayProps} from '../AnchoredOverlay'
44
import {AnchoredOverlay} from '../AnchoredOverlay'
55
import type {OverlayProps} from '../Overlay'
@@ -13,11 +13,16 @@ import type {MandateProps} from '../utils/types'
1313
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
1414
import {Tooltip} from '../TooltipV2/Tooltip'
1515

16+
export type MenuCloseHandler = (
17+
gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab' | 'item-select' | 'arrow-left',
18+
) => void
19+
1620
export type MenuContextProps = Pick<
1721
AnchoredOverlayProps,
1822
'anchorRef' | 'renderAnchor' | 'open' | 'onOpen' | 'anchorId'
1923
> & {
20-
onClose?: (gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab') => void
24+
onClose?: MenuCloseHandler
25+
isSubmenu?: boolean
2126
}
2227
const MenuContext = React.createContext<MenuContextProps>({renderAnchor: null, open: false})
2328

@@ -44,9 +49,23 @@ const Menu: React.FC<React.PropsWithChildren<ActionMenuProps>> = ({
4449
onOpenChange,
4550
children,
4651
}: ActionMenuProps) => {
52+
const parentMenuContext = useContext(MenuContext)
53+
4754
const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, onOpenChange, false)
4855
const onOpen = React.useCallback(() => setCombinedOpenState(true), [setCombinedOpenState])
49-
const onClose = React.useCallback(() => setCombinedOpenState(false), [setCombinedOpenState])
56+
const onClose: MenuCloseHandler = React.useCallback(
57+
gesture => {
58+
setCombinedOpenState(false)
59+
60+
// Close the parent stack when an item is selected or the user tabs out of the menu entirely
61+
switch (gesture) {
62+
case 'tab':
63+
case 'item-select':
64+
parentMenuContext.onClose?.(gesture)
65+
}
66+
},
67+
[setCombinedOpenState, parentMenuContext],
68+
)
5069

5170
const menuButtonChild = React.Children.toArray(children).find(
5271
child => React.isValidElement<ActionMenuButtonProps>(child) && (child.type === MenuButton || child.type === Anchor),
@@ -100,15 +119,59 @@ const Menu: React.FC<React.PropsWithChildren<ActionMenuProps>> = ({
100119
})
101120

102121
return (
103-
<MenuContext.Provider value={{anchorRef, renderAnchor, anchorId, open: combinedOpenState, onOpen, onClose}}>
122+
<MenuContext.Provider
123+
value={{
124+
anchorRef,
125+
renderAnchor,
126+
anchorId,
127+
open: combinedOpenState,
128+
onOpen,
129+
onClose,
130+
// will be undefined for the outermost level, then false for the top menu, then true inside that
131+
isSubmenu: parentMenuContext.isSubmenu !== undefined,
132+
}}
133+
>
104134
{contents}
105135
</MenuContext.Provider>
106136
)
107137
}
108138

109139
export type ActionMenuAnchorProps = {children: React.ReactElement; id?: string}
110140
const Anchor = React.forwardRef<HTMLElement, ActionMenuAnchorProps>(({children, ...anchorProps}, anchorRef) => {
111-
return React.cloneElement(children, {...anchorProps, ref: anchorRef})
141+
const {onOpen, isSubmenu} = React.useContext(MenuContext)
142+
143+
const openSubmenuOnRightArrow: React.KeyboardEventHandler<HTMLElement> = useCallback(
144+
event => {
145+
children.props.onKeyDown?.(event)
146+
if (isSubmenu && event.key === 'ArrowRight' && !event.defaultPrevented) onOpen?.('anchor-key-press')
147+
},
148+
[children, isSubmenu, onOpen],
149+
)
150+
151+
// Add right chevron icon to submenu anchors rendered using `ActionList.Item`
152+
const parentActionListContext = useContext(ActionListContainerContext)
153+
const thisActionListContext = useMemo(
154+
() =>
155+
isSubmenu
156+
? {
157+
...parentActionListContext,
158+
defaultTrailingVisual: <ChevronRightIcon />,
159+
// Default behavior is to close after selecting; we want to open the submenu instead
160+
afterSelect: () => onOpen?.('anchor-click'),
161+
}
162+
: parentActionListContext,
163+
[isSubmenu, onOpen, parentActionListContext],
164+
)
165+
166+
return (
167+
<ActionListContainerContext.Provider value={thisActionListContext}>
168+
{React.cloneElement(children, {
169+
...anchorProps,
170+
ref: anchorRef,
171+
onKeyDown: openSubmenuOnRightArrow,
172+
})}
173+
</ActionListContainerContext.Provider>
174+
)
112175
})
113176

114177
/** this component is syntactical sugar 🍭 */
@@ -133,19 +196,24 @@ type MenuOverlayProps = Partial<OverlayProps> &
133196
const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
134197
children,
135198
align = 'start',
136-
side = 'outside-bottom',
199+
side,
137200
'aria-labelledby': ariaLabelledby,
138201
...overlayProps
139202
}) => {
140203
// we typecast anchorRef as required instead of optional
141204
// because we know that we're setting it in context in Menu
142-
const {anchorRef, renderAnchor, anchorId, open, onOpen, onClose} = React.useContext(MenuContext) as MandateProps<
143-
MenuContextProps,
144-
'anchorRef'
145-
>
205+
const {
206+
anchorRef,
207+
renderAnchor,
208+
anchorId,
209+
open,
210+
onOpen,
211+
onClose,
212+
isSubmenu = false,
213+
} = React.useContext(MenuContext) as MandateProps<MenuContextProps, 'anchorRef'>
146214

147215
const containerRef = React.useRef<HTMLDivElement>(null)
148-
useMenuKeyboardNavigation(open, onClose, containerRef, anchorRef)
216+
useMenuKeyboardNavigation(open, onClose, containerRef, anchorRef, isSubmenu)
149217

150218
// If the menu anchor is an icon button, we need to label the menu by tooltip that also labelled the anchor.
151219
const [anchorAriaLabelledby, setAnchorAriaLabelledby] = useState<null | string>(null)
@@ -167,7 +235,7 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
167235
onOpen={onOpen}
168236
onClose={onClose}
169237
align={align}
170-
side={side}
238+
side={side ?? (isSubmenu ? 'outside-right' : 'outside-bottom')}
171239
overlayProps={overlayProps}
172240
focusZoneSettings={{focusOutBehavior: 'wrap'}}
173241
>
@@ -179,7 +247,7 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({
179247
// 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.
180248
listLabelledBy: ariaLabelledby || anchorAriaLabelledby || anchorId,
181249
selectionAttribute: 'aria-checked', // Should this be here?
182-
afterSelect: onClose,
250+
afterSelect: () => onClose?.('item-select'),
183251
}}
184252
>
185253
{children}

0 commit comments

Comments
 (0)