Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
8 changes: 8 additions & 0 deletions packages/react/src/ActionMenu/ActionMenu.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@
"url": "/react/Button"
}
},
{
"name": "ActionMenu.IconButton",
"props": [],
"passthrough": {
"element": "IconButton",
"url": "/react/IconButton"
}
},
{
"name": "ActionMenu.Anchor",
"props": [
Expand Down
166 changes: 166 additions & 0 deletions packages/react/src/ActionMenu/ActionMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,172 @@ describe('ActionMenu', () => {
})
})

describe('ActionMenu.IconButton', () => {
it('should open Menu on ActionMenu.IconButton click', async () => {
const component = HTMLRender(
<ActionMenu>
<ActionMenu.IconButton icon={KebabHorizontalIcon} aria-label="Open menu" />
<ActionMenu.Overlay>
<ActionList>
<ActionList.Item>New file</ActionList.Item>
<ActionList.Item>Copy link</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>,
)

const user = userEvent.setup()
const button = component.getByRole('button', {name: 'Open menu'})
await user.click(button)

expect(component.getByRole('menu')).toBeInTheDocument()
})

it('should close Menu on selecting an action when using ActionMenu.IconButton', async () => {
const component = HTMLRender(
<ActionMenu>
<ActionMenu.IconButton icon={KebabHorizontalIcon} aria-label="Open menu" />
<ActionMenu.Overlay>
<ActionList>
<ActionList.Item>New file</ActionList.Item>
<ActionList.Item>Copy link</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>,
)

const user = userEvent.setup()
const button = component.getByRole('button', {name: 'Open menu'})
await user.click(button)

const menuItems = component.getAllByRole('menuitem')
await user.click(menuItems[0])

expect(component.queryByRole('menu')).toBeNull()
})

it('should call onClick and onKeyDown passed to ActionMenu.IconButton', async () => {
const mockOnClick = vi.fn()
const mockOnKeyDown = vi.fn()

const component = HTMLRender(
<ActionMenu>
<ActionMenu.IconButton
icon={KebabHorizontalIcon}
aria-label="Open menu"
onClick={mockOnClick}
onKeyDown={mockOnKeyDown}
/>
<ActionMenu.Overlay>
<ActionList>
<ActionList.Item>New file</ActionList.Item>
<ActionList.Item>Copy link</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>,
)

const user = userEvent.setup()
const button = component.getByRole('button')
await user.click(button)

expect(component.getByRole('menu')).toBeInTheDocument()
expect(mockOnClick).toHaveBeenCalledTimes(1)

// select and close menu
const menuItems = component.getAllByRole('menuitem')
await user.click(menuItems[0])
expect(component.queryByRole('menu')).toBeNull()

expect(button).toEqual(document.activeElement)
await user.keyboard('{Enter}')
expect(component.queryByRole('menu')).toBeInTheDocument()
expect(mockOnKeyDown).toHaveBeenCalledTimes(1)
})

it('should pass the "id" prop from ActionMenu.IconButton to the HTML button', async () => {
const buttonId = 'icon-button-custom-id'
const component = HTMLRender(
<ActionMenu>
<ActionMenu.IconButton id={buttonId} icon={KebabHorizontalIcon} aria-label="Open menu" />
<ActionMenu.Overlay>
<ActionList>
<ActionList.Item>New file</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>,
)
const button = component.getByRole('button')

expect(button.id).toBe(buttonId)
})
})

describe('ActionMenu.Anchor validation', () => {
it('should warn when ActionMenu.Anchor receives a non-interactive child', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

HTMLRender(
<ActionMenu>
<ActionMenu.Anchor>
<div>Not a button</div>
</ActionMenu.Anchor>
<ActionMenu.Overlay>
<ActionList>
<ActionList.Item>New file</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>,
)

expect(consoleSpy).toHaveBeenCalledWith('Warning:', expect.stringContaining('ActionMenu.Anchor'))

consoleSpy.mockRestore()
})

it('should not warn when ActionMenu.Anchor receives a Button child', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

HTMLRender(
<ActionMenu>
<ActionMenu.Anchor>
<Button>Toggle Menu</Button>
</ActionMenu.Anchor>
<ActionMenu.Overlay>
<ActionList>
<ActionList.Item>New file</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>,
)

expect(consoleSpy).not.toHaveBeenCalled()

consoleSpy.mockRestore()
})

it('should not warn when ActionMenu.Anchor receives an IconButton child', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

HTMLRender(
<ActionMenu>
<ActionMenu.Anchor>
<IconButton icon={KebabHorizontalIcon} aria-label="Open menu" />
</ActionMenu.Anchor>
<ActionMenu.Overlay>
<ActionList>
<ActionList.Item>New file</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>,
)

expect(consoleSpy).not.toHaveBeenCalled()

consoleSpy.mockRestore()
})
})

describe('feature flag: primer_react_action_menu_display_in_viewport_inside_dialog', () => {
const mockGetAnchoredPosition = vi.mocked(getAnchoredPosition)

Expand Down
95 changes: 87 additions & 8 deletions packages/react/src/ActionMenu/ActionMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import React, {useCallback, useContext, useMemo, useEffect, useState} from 'react'
import React, {useCallback, useContext, useMemo, useEffect, useRef, useState} 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 {Divider} from '../ActionList/Divider'
import {ActionListContainerContext} from '../ActionList/ActionListContainerContext'
import type {ButtonProps} from '../Button'
import type {ButtonProps, IconButtonProps} from '../Button'
import type {AnchorPosition} from '@primer/behaviors'
import {Button} from '../Button'
import {Button, IconButton} from '../Button'
import {useId} from '../hooks/useId'
import type {MandateProps} from '../utils/types'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
Expand All @@ -19,6 +19,7 @@ import {isSlot} from '../utils/is-slot'
import type {FCWithSlotMarker, WithSlotMarker} from '../utils/types/Slots'
import {useFeatureFlag} from '../FeatureFlags'
import {DialogContext} from '../Dialog/Dialog'
import {warning} from '../utils/warning'

export type MenuCloseHandler = (
gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab' | 'item-select' | 'arrow-left' | 'close',
Expand Down Expand Up @@ -106,7 +107,12 @@ const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({
const menuButtonChild = React.Children.toArray(children).find(
child =>
React.isValidElement<ActionMenuButtonProps>(child) &&
(child.type === MenuButton || child.type === Anchor || isSlot(child, Anchor) || isSlot(child, MenuButton)),
(child.type === MenuButton ||
child.type === MenuIconButton ||
child.type === Anchor ||
isSlot(child, Anchor) ||
isSlot(child, MenuButton) ||
isSlot(child, MenuIconButton)),
)
const menuButtonChildId = React.isValidElement(menuButtonChild) ? menuButtonChild.props.id : undefined

Expand All @@ -122,7 +128,12 @@ const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({
if (child.type === Tooltip || isSlot(child, Tooltip)) {
// tooltip trigger
const anchorChildren = child.props.children
if (anchorChildren.type === MenuButton || isSlot(anchorChildren, MenuButton)) {
if (
anchorChildren.type === MenuButton ||
isSlot(anchorChildren, MenuButton) ||
anchorChildren.type === MenuIconButton ||
isSlot(anchorChildren, MenuIconButton)
) {
// eslint-disable-next-line react-hooks/immutability
renderAnchor = anchorProps => {
// We need to attach the anchor props to the tooltip trigger (ActionMenu.Button's grandchild) not the tooltip itself.
Expand Down Expand Up @@ -156,7 +167,12 @@ const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({
renderAnchor = anchorProps => React.cloneElement(child, anchorProps)
}
return null
} else if (child.type === MenuButton || isSlot(child, MenuButton)) {
} else if (
child.type === MenuButton ||
isSlot(child, MenuButton) ||
child.type === MenuIconButton ||
isSlot(child, MenuIconButton)
) {
renderAnchor = anchorProps => React.cloneElement(child, mergeAnchorHandlers(anchorProps, child.props))
return null
} else {
Expand All @@ -182,6 +198,23 @@ const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({
)
}

// The list is from GitHub's custom-axe-rules https://github.com/github/github/blob/master/app/assets/modules/github/axe-custom-rules.ts#L3
const interactiveElements = [
'a[href]',
'button:not([disabled])',
'summary',
'select',
'input:not([type=hidden])',
'textarea',
]

const isInteractive = (element: HTMLElement) => {
return (
interactiveElements.some(selector => element.matches(selector)) ||
(element.hasAttribute('role') && element.getAttribute('role') === 'button')
)
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ActionMenuAnchorProps = {children: React.ReactElement<any>; id?: string} & React.HTMLAttributes<HTMLElement>
const Anchor: WithSlotMarker<
Expand All @@ -195,6 +228,26 @@ const Anchor: WithSlotMarker<
>
> = React.forwardRef<HTMLElement, ActionMenuAnchorProps>(({children: child, ...anchorProps}, anchorRef) => {
const {onOpen, isSubmenu} = React.useContext(MenuContext)
const internalRef = useRef<HTMLElement | null>(null) as React.MutableRefObject<HTMLElement | null>

useEffect(() => {
// Skip validation for submenu anchors, where ActionList.Item is a valid anchor
if (isSubmenu) return
if (!internalRef.current) return
const anchorElement = internalRef.current
const isAnchorInteractive = isInteractive(anchorElement)
const anchorChildren = anchorElement.childNodes
const hasInteractiveDescendant = Array.from(anchorChildren).some(child => {
return (
(child instanceof HTMLElement && isInteractive(child)) ||
Array.from(child.childNodes).some(grandChild => grandChild instanceof HTMLElement && isInteractive(grandChild))
)
})
warning(
!isAnchorInteractive && !hasInteractiveDescendant,
'The `ActionMenu.Anchor` expects a single interactive element that can act as a menu trigger. Consider using a `<button>` or an `IconButton` as the direct child of `ActionMenu.Anchor`, or use `ActionMenu.Button` or `ActionMenu.IconButton` instead.',
)
}, [isSubmenu])

const openSubmenuOnRightArrow: React.KeyboardEventHandler<HTMLElement> = useCallback(
event => {
Expand Down Expand Up @@ -231,9 +284,17 @@ const Anchor: WithSlotMarker<

return (
<ActionListContainerContext.Provider value={thisActionListContext}>
{/* eslint-disable-next-line react-hooks/refs */}
{React.cloneElement(child, {
...anchorProps,
ref: anchorRef,
ref: (node: HTMLElement | null) => {
internalRef.current = node
if (typeof anchorRef === 'function') {
anchorRef(node)
} else if (anchorRef) {
;(anchorRef as React.MutableRefObject<HTMLElement | null>).current = node
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The anchor ref wiring is manually re-implementing ref merging and requires unsafe casts. Consider using the existing useMergedRefs helper (packages/react/src/internal/hooks/useMergedRefs.ts) to combine anchorRef and internalRef instead of an inline callback + casts; this also lets you drop the react-hooks/refs eslint disable.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

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

@copilot apply changes based on this feedback

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Replaced manual ref merging with useMergedRefs(internalRef, anchorRef) in d6d9c33. This removes the inline callback, the unsafe MutableRefObject cast, and the eslint-disable comment.

},
onClick: onButtonClick,
onKeyDown: onButtonKeyDown,
})}
Expand All @@ -252,6 +313,17 @@ const MenuButton = React.forwardRef(({...props}, anchorRef) => {
)
}) as PolymorphicForwardRefComponent<'button', ActionMenuButtonProps>

/** this component is syntactical sugar 🍭 */
export type ActionMenuIconButtonProps = IconButtonProps

const MenuIconButton = React.forwardRef(({...props}, anchorRef) => {
return (
<Anchor ref={anchorRef}>
<IconButton type="button" {...props} />
</Anchor>
)
}) as PolymorphicForwardRefComponent<'button', ActionMenuIconButtonProps>

const defaultVariant: ResponsiveValue<'anchored', 'anchored' | 'fullscreen'> = {
regular: 'anchored',
narrow: 'anchored',
Expand Down Expand Up @@ -373,7 +445,14 @@ Menu.displayName = 'ActionMenu'

Menu.__SLOT__ = Symbol('ActionMenu')
MenuButton.__SLOT__ = Symbol('ActionMenu.Button')
MenuIconButton.__SLOT__ = Symbol('ActionMenu.IconButton')
Anchor.__SLOT__ = Symbol('ActionMenu.Anchor')
Overlay.__SLOT__ = Symbol('ActionMenu.Overlay')

export const ActionMenu = Object.assign(Menu, {Button: MenuButton, Anchor, Overlay, Divider})
export const ActionMenu = Object.assign(Menu, {
Button: MenuButton,
IconButton: MenuIconButton,
Anchor,
Overlay,
Divider,
})
14 changes: 14 additions & 0 deletions packages/react/src/ActionMenu/ActionMenu.types.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {ActionMenu} from '..'
import {SearchIcon} from '@primer/octicons-react'

export function actionButtonWithoutProps() {
return <ActionMenu.Button />
Expand All @@ -16,3 +17,16 @@ export function actionButtonWithInvalidSize() {
//@ts-expect-error size must be one of the valid values
return <ActionMenu.Button size="some-unknownsize">Click me!</ActionMenu.Button>
}

export function actionIconButtonWithRequiredProps() {
return <ActionMenu.IconButton icon={SearchIcon} aria-label="Search" />
}

export function actionIconButtonWithOptionalProps() {
return <ActionMenu.IconButton icon={SearchIcon} aria-label="Search" size="small" />
}

export function actionIconButtonWithInvalidSize() {
//@ts-expect-error size must be one of the valid values
return <ActionMenu.IconButton icon={SearchIcon} aria-label="Search" size="some-unknownsize" />
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] =
"ActionMenu",
"type ActionMenuAnchorProps",
"type ActionMenuButtonProps",
"type ActionMenuIconButtonProps",
"type ActionMenuProps",
"AnchoredOverlay",
"type AnchoredOverlayProps",
Expand Down
Loading
Loading