diff --git a/packages/react/src/ActionMenu/ActionMenu.docs.json b/packages/react/src/ActionMenu/ActionMenu.docs.json index 23a8ed07e52..0ebdd44fbe6 100644 --- a/packages/react/src/ActionMenu/ActionMenu.docs.json +++ b/packages/react/src/ActionMenu/ActionMenu.docs.json @@ -71,6 +71,14 @@ "url": "/react/Button" } }, + { + "name": "ActionMenu.IconButton", + "props": [], + "passthrough": { + "element": "IconButton", + "url": "/react/IconButton" + } + }, { "name": "ActionMenu.Anchor", "props": [ 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..296d293f430 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,8 @@ 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' +import {useMergedRefs} from '../internal/hooks/useMergedRefs' export type MenuCloseHandler = ( gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab' | 'item-select' | 'arrow-left' | 'close', @@ -106,7 +108,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 +129,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 +168,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 +199,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 +229,21 @@ const Anchor: WithSlotMarker< > > = React.forwardRef(({children: child, ...anchorProps}, anchorRef) => { const {onOpen, isSubmenu} = React.useContext(MenuContext) + const internalRef = useRef(null) + const mergedRef = useMergedRefs(internalRef, anchorRef) + + useEffect(() => { + if (!__DEV__) return + // 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) + warning( + !isAnchorInteractive, + 'The `ActionMenu.Anchor` expects a single interactive element that can act as a menu trigger. Consider using a `