From c732051b328c9837b1b76dcb50a87aa8d713f9bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:53:36 +0000 Subject: [PATCH 1/6] Initial plan From af9631546b66f824593276996ff60a8130ca6f5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:09:21 +0000 Subject: [PATCH 2/6] Add ActionMenu.IconButton and ActionMenu.Anchor validation - Add ActionMenu.IconButton as a shortcut companion to ActionMenu.Button that wraps IconButton and wires it correctly as an ActionMenu anchor - Add validation in ActionMenu.Anchor to warn when the child is a non-interactive element (skips validation for submenu anchors) - Add tests for both features - Add type tests for ActionMenu.IconButton - Update exports in index.ts Co-authored-by: siddharthkp <1863771+siddharthkp@users.noreply.github.com> --- .../react/src/ActionMenu/ActionMenu.test.tsx | 166 ++++++++++++++++++ packages/react/src/ActionMenu/ActionMenu.tsx | 95 +++++++++- .../src/ActionMenu/ActionMenu.types.test.tsx | 14 ++ packages/react/src/index.ts | 7 +- 4 files changed, 273 insertions(+), 9 deletions(-) diff --git a/packages/react/src/ActionMenu/ActionMenu.test.tsx b/packages/react/src/ActionMenu/ActionMenu.test.tsx index f7e74c3db77..7a5c6c2a7b9 100644 --- a/packages/react/src/ActionMenu/ActionMenu.test.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.test.tsx @@ -837,6 +837,172 @@ describe('ActionMenu', () => { }) }) + describe('ActionMenu.IconButton', () => { + it('should open Menu on ActionMenu.IconButton click', async () => { + const component = HTMLRender( + + + + + New file + Copy link + + + , + ) + + 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( + + + + + New file + Copy link + + + , + ) + + 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( + + + + + New file + Copy link + + + , + ) + + 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( + + + + + New file + + + , + ) + 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( + + +
Not a button
+
+ + + New file + + +
, + ) + + 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( + + + + + + + New file + + + , + ) + + 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( + + + + + + + New file + + + , + ) + + expect(consoleSpy).not.toHaveBeenCalled() + + consoleSpy.mockRestore() + }) + }) + describe('feature flag: primer_react_action_menu_display_in_viewport_inside_dialog', () => { const mockGetAnchoredPosition = vi.mocked(getAnchoredPosition) diff --git a/packages/react/src/ActionMenu/ActionMenu.tsx b/packages/react/src/ActionMenu/ActionMenu.tsx index e501b117377..4f1544f9dd2 100644 --- a/packages/react/src/ActionMenu/ActionMenu.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.tsx @@ -1,4 +1,4 @@ -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' @@ -6,9 +6,9 @@ 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' @@ -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', @@ -106,7 +107,12 @@ const Menu: FCWithSlotMarker> = ({ const menuButtonChild = React.Children.toArray(children).find( child => React.isValidElement(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 @@ -122,7 +128,12 @@ const Menu: FCWithSlotMarker> = ({ 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. @@ -156,7 +167,12 @@ const Menu: FCWithSlotMarker> = ({ 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 { @@ -182,6 +198,23 @@ const Menu: FCWithSlotMarker> = ({ ) } +// 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; id?: string} & React.HTMLAttributes const Anchor: WithSlotMarker< @@ -195,6 +228,26 @@ const Anchor: WithSlotMarker< > > = React.forwardRef(({children: child, ...anchorProps}, anchorRef) => { const {onOpen, isSubmenu} = React.useContext(MenuContext) + const internalRef = useRef(null) as React.MutableRefObject + + 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 `