diff --git a/.changeset/sour-games-tap.md b/.changeset/sour-games-tap.md new file mode 100644 index 00000000000..3fe24af9519 --- /dev/null +++ b/.changeset/sour-games-tap.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +ActionMenu: Automatically wires the ActionMenu component to the accessibility and validation provided by the FormControl component it's nested within. If ActionMenu isn't nested within FormControl, nothing changes. diff --git a/packages/react/src/ActionMenu/ActionMenu.examples.stories.tsx b/packages/react/src/ActionMenu/ActionMenu.examples.stories.tsx index d636102cd86..6db449fc2be 100644 --- a/packages/react/src/ActionMenu/ActionMenu.examples.stories.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.examples.stories.tsx @@ -483,3 +483,20 @@ export const OnlyInactiveItems = () => ( ) + +export const WithinForm = () => ( + + Action Menu within FormControl + + Open menu + + + alert('Copy link clicked')}> + Copy link + ⌘C + + + + + +) diff --git a/packages/react/src/ActionMenu/ActionMenu.tsx b/packages/react/src/ActionMenu/ActionMenu.tsx index 24b53b7025c..3f6349fffad 100644 --- a/packages/react/src/ActionMenu/ActionMenu.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.tsx @@ -12,6 +12,7 @@ import {useId} from '../hooks/useId' import type {MandateProps} from '../utils/types' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {Tooltip} from '../TooltipV2/Tooltip' +import {useFormControlForwardedProps} from '../FormControl' export type MenuContextProps = Pick< AnchoredOverlayProps, @@ -43,10 +44,12 @@ const Menu: React.FC> = ({ open, onOpenChange, children, + ...externalInputProps }: ActionMenuProps) => { const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, onOpenChange, false) const onOpen = React.useCallback(() => setCombinedOpenState(true), [setCombinedOpenState]) const onClose = React.useCallback(() => setCombinedOpenState(false), [setCombinedOpenState]) + const inputProps = useFormControlForwardedProps(externalInputProps) const menuButtonChild = React.Children.toArray(children).find( child => React.isValidElement(child) && (child.type === MenuButton || child.type === Anchor), @@ -69,7 +72,7 @@ const Menu: React.FC> = ({ renderAnchor = anchorProps => { // We need to attach the anchor props to the tooltip trigger (ActionMenu.Button's grandchild) not the tooltip itself. const triggerButton = React.cloneElement(anchorChildren, {...anchorProps}) - return React.cloneElement(child, {children: triggerButton, ref: anchorRef}) + return React.cloneElement(child, {children: triggerButton, ref: anchorRef, ...inputProps}) } } return null @@ -84,15 +87,15 @@ const Menu: React.FC> = ({ // We need to attach the anchor props to the tooltip trigger not the tooltip itself. const tooltipTriggerEl = React.cloneElement(tooltipTrigger, {...anchorProps}) const tooltip = React.cloneElement(anchorChildren, {children: tooltipTriggerEl}) - return React.cloneElement(child, {children: tooltip, ref: anchorRef}) + return React.cloneElement(child, {children: tooltip, ref: anchorRef, ...inputProps}) } } } else { - renderAnchor = anchorProps => React.cloneElement(child, anchorProps) + renderAnchor = anchorProps => React.cloneElement(child, {...anchorProps, ...inputProps}) } return null } else if (child.type === MenuButton) { - renderAnchor = anchorProps => React.cloneElement(child, anchorProps) + renderAnchor = anchorProps => React.cloneElement(child, {...anchorProps, ...inputProps}) return null } else { return child diff --git a/packages/react/src/__tests__/ActionMenu.test.tsx b/packages/react/src/__tests__/ActionMenu.test.tsx index e2c871c931f..64739921e85 100644 --- a/packages/react/src/__tests__/ActionMenu.test.tsx +++ b/packages/react/src/__tests__/ActionMenu.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event' import {axe} from 'jest-axe' import React from 'react' import theme from '../theme' -import {ActionMenu, ActionList, BaseStyles, ThemeProvider, SSRProvider, Tooltip, Button} from '..' +import {ActionMenu, ActionList, BaseStyles, ThemeProvider, SSRProvider, Tooltip, Button, FormControl} from '..' import {Tooltip as TooltipV2} from '../TooltipV2/Tooltip' import {behavesAsComponent, checkExports} from '../utils/testing' import {SingleSelect} from '../ActionMenu/ActionMenu.features.stories' @@ -77,6 +77,24 @@ function ExampleWithTooltipV2(actionMenuTrigger: React.ReactElement): JSX.Elemen ) } +function ExampleWithForm(): JSX.Element { + return ( + + Action Menu Label + + + ) +} + +function ExampleWithTooltipWithForm(): JSX.Element { + return ( + + Action Menu Label + + + ) +} + describe('ActionMenu', () => { behavesAsComponent({ Component: ActionList, @@ -398,4 +416,20 @@ describe('ActionMenu', () => { expect(button.id).toBe(buttonId) }) + + it('MenuButton within FormControl should be labelled by FormControl.Label', async () => { + const component = HTMLRender() + const button = component.getByLabelText('Action Menu Label') + expect(button).toBeVisible() + const buttonByRole = component.getByRole('button') + expect(button.id).toBe(buttonByRole.id) + }) + + it('MenuButton wrapped by Tooltip within FormControl should be labelled by FormControl.Label', async () => { + const component = HTMLRender() + const button = component.getByLabelText('Action Menu Label') + expect(button).toBeVisible() + const buttonByRole = component.getByRole('button') + expect(button.id).toBe(buttonByRole.id) + }) })