Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds FormControl's "Auto-Wiring" to ActionMenu Component #4372

Closed
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/sour-games-tap.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions packages/react/src/ActionMenu/ActionMenu.examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -483,3 +483,20 @@ export const OnlyInactiveItems = () => (
</ActionMenu.Overlay>
</ActionMenu>
)

export const WithinForm = () => (
<FormControl>
<FormControl.Label>Action Menu within FormControl</FormControl.Label>
<ActionMenu>
<ActionMenu.Button>Open menu</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList>
<ActionList.Item onSelect={() => alert('Copy link clicked')}>
Copy link
<ActionList.TrailingVisual>⌘C</ActionList.TrailingVisual>
</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</FormControl>
)
11 changes: 7 additions & 4 deletions packages/react/src/ActionMenu/ActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -43,10 +44,12 @@ const Menu: React.FC<React.PropsWithChildren<ActionMenuProps>> = ({
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)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Using the hook recently created by this PR: #3632. This hook is also being used in the InlineAutocomplete draft component:

const inputProps = useFormControlForwardedProps(externalInputProps)


const menuButtonChild = React.Children.toArray(children).find(
child => React.isValidElement<ActionMenuButtonProps>(child) && (child.type === MenuButton || child.type === Anchor),
Expand All @@ -69,7 +72,7 @@ const Menu: React.FC<React.PropsWithChildren<ActionMenuProps>> = ({
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})
Copy link
Contributor Author

Choose a reason for hiding this comment

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

}
}
return null
Expand All @@ -84,15 +87,15 @@ const Menu: React.FC<React.PropsWithChildren<ActionMenuProps>> = ({
// 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
Expand Down
168 changes: 84 additions & 84 deletions packages/react/src/Token/__tests__/__snapshots__/Token.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,6 @@ exports[`Token components AvatarToken renders all sizes 1`] = `
border-width: 0;
}

.c3 {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I updated snapshots as detailed in the contributor docs: https://github.com/primer/react/blob/cb54f4298700d369e616aaefb2e7df4830231736/contributor-docs/testing.md#snapshots

Not sure why these classes had to be rearranged as it's not clear to me how Token relates to my ActionMenu changes at all

display: inline-block;
overflow: hidden;
line-height: 1;
vertical-align: middle;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--avatar-borderColor,var(--color-avatar-border,rgba(31,35,40,0.15)));
height: var(--avatar-size);
width: var(--avatar-size);
--avatar-size: 16px;
width: 100%;
height: 100%;
}

.c0 {
-webkit-align-items: center;
-webkit-box-align: center;
Expand Down Expand Up @@ -155,6 +141,20 @@ exports[`Token components AvatarToken renders all sizes 1`] = `
bottom: 0;
}

.c3 {
display: inline-block;
overflow: hidden;
line-height: 1;
vertical-align: middle;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--avatar-borderColor,var(--color-avatar-border,rgba(31,35,40,0.15)));
height: var(--avatar-size);
width: var(--avatar-size);
--avatar-size: 16px;
width: 100%;
height: 100%;
}

.c2 {
--spacing: calc(4px * 2);
display: block;
Expand Down Expand Up @@ -248,20 +248,6 @@ exports[`Token components AvatarToken renders all sizes 2`] = `
border-width: 0;
}

.c3 {
display: inline-block;
overflow: hidden;
line-height: 1;
vertical-align: middle;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--avatar-borderColor,var(--color-avatar-border,rgba(31,35,40,0.15)));
height: var(--avatar-size);
width: var(--avatar-size);
--avatar-size: 20px;
width: 100%;
height: 100%;
}

.c0 {
-webkit-align-items: center;
-webkit-box-align: center;
Expand Down Expand Up @@ -381,6 +367,20 @@ exports[`Token components AvatarToken renders all sizes 2`] = `
bottom: 0;
}

.c3 {
display: inline-block;
overflow: hidden;
line-height: 1;
vertical-align: middle;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--avatar-borderColor,var(--color-avatar-border,rgba(31,35,40,0.15)));
height: var(--avatar-size);
width: var(--avatar-size);
--avatar-size: 20px;
width: 100%;
height: 100%;
}

.c2 {
--spacing: calc(4px * 2);
display: block;
Expand Down Expand Up @@ -474,20 +474,6 @@ exports[`Token components AvatarToken renders all sizes 3`] = `
border-width: 0;
}

.c3 {
display: inline-block;
overflow: hidden;
line-height: 1;
vertical-align: middle;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--avatar-borderColor,var(--color-avatar-border,rgba(31,35,40,0.15)));
height: var(--avatar-size);
width: var(--avatar-size);
--avatar-size: 24px;
width: 100%;
height: 100%;
}

.c0 {
-webkit-align-items: center;
-webkit-box-align: center;
Expand Down Expand Up @@ -607,6 +593,20 @@ exports[`Token components AvatarToken renders all sizes 3`] = `
bottom: 0;
}

.c3 {
display: inline-block;
overflow: hidden;
line-height: 1;
vertical-align: middle;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--avatar-borderColor,var(--color-avatar-border,rgba(31,35,40,0.15)));
height: var(--avatar-size);
width: var(--avatar-size);
--avatar-size: 24px;
width: 100%;
height: 100%;
}

.c2 {
--spacing: calc(4px * 2);
display: block;
Expand Down Expand Up @@ -700,20 +700,6 @@ exports[`Token components AvatarToken renders all sizes 4`] = `
border-width: 0;
}

.c3 {
display: inline-block;
overflow: hidden;
line-height: 1;
vertical-align: middle;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--avatar-borderColor,var(--color-avatar-border,rgba(31,35,40,0.15)));
height: var(--avatar-size);
width: var(--avatar-size);
--avatar-size: 32px;
width: 100%;
height: 100%;
}

.c0 {
-webkit-align-items: center;
-webkit-box-align: center;
Expand Down Expand Up @@ -835,6 +821,20 @@ exports[`Token components AvatarToken renders all sizes 4`] = `
bottom: 0;
}

.c3 {
display: inline-block;
overflow: hidden;
line-height: 1;
vertical-align: middle;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--avatar-borderColor,var(--color-avatar-border,rgba(31,35,40,0.15)));
height: var(--avatar-size);
width: var(--avatar-size);
--avatar-size: 32px;
width: 100%;
height: 100%;
}

.c2 {
--spacing: calc(4px * 2);
display: block;
Expand Down Expand Up @@ -915,20 +915,6 @@ exports[`Token components AvatarToken renders isSelected 1`] = `
margin-right: 4px;
}

.c3 {
display: inline-block;
overflow: hidden;
line-height: 1;
vertical-align: middle;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--avatar-borderColor,var(--color-avatar-border,rgba(31,35,40,0.15)));
height: var(--avatar-size);
width: var(--avatar-size);
--avatar-size: 20px;
width: 100%;
height: 100%;
}

.c0 {
-webkit-align-items: center;
-webkit-box-align: center;
Expand Down Expand Up @@ -998,6 +984,20 @@ exports[`Token components AvatarToken renders isSelected 1`] = `
bottom: 0;
}

.c3 {
display: inline-block;
overflow: hidden;
line-height: 1;
vertical-align: middle;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--avatar-borderColor,var(--color-avatar-border,rgba(31,35,40,0.15)));
height: var(--avatar-size);
width: var(--avatar-size);
--avatar-size: 20px;
width: 100%;
height: 100%;
}

.c2 {
--spacing: calc(4px * 2);
display: block;
Expand Down Expand Up @@ -1056,20 +1056,6 @@ exports[`Token components AvatarToken renders with a remove button 1`] = `
border-width: 0;
}

.c3 {
display: inline-block;
overflow: hidden;
line-height: 1;
vertical-align: middle;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--avatar-borderColor,var(--color-avatar-border,rgba(31,35,40,0.15)));
height: var(--avatar-size);
width: var(--avatar-size);
--avatar-size: 20px;
width: 100%;
height: 100%;
}

.c0 {
-webkit-align-items: center;
-webkit-box-align: center;
Expand Down Expand Up @@ -1189,6 +1175,20 @@ exports[`Token components AvatarToken renders with a remove button 1`] = `
bottom: 0;
}

.c3 {
display: inline-block;
overflow: hidden;
line-height: 1;
vertical-align: middle;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--avatar-borderColor,var(--color-avatar-border,rgba(31,35,40,0.15)));
height: var(--avatar-size);
width: var(--avatar-size);
--avatar-size: 20px;
width: 100%;
height: 100%;
}

.c2 {
--spacing: calc(4px * 2);
display: block;
Expand Down
36 changes: 35 additions & 1 deletion packages/react/src/__tests__/ActionMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -77,6 +77,24 @@ function ExampleWithTooltipV2(actionMenuTrigger: React.ReactElement): JSX.Elemen
)
}

function ExampleWithForm(): JSX.Element {
return (
<FormControl>
<FormControl.Label>Action Menu Label</FormControl.Label>
<Example />
</FormControl>
)
}

function ExampleWithTooltipWithForm(): JSX.Element {
return (
<FormControl>
<FormControl.Label>Action Menu Label</FormControl.Label>
<ExampleWithTooltip />
</FormControl>
)
}

Copy link
Contributor Author

@JeffreyKozik JeffreyKozik Mar 13, 2024

Choose a reason for hiding this comment

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

I tried adding a test case for TooltipV2

function ExampleWithTooltipV2WithForm(): JSX.Element {
  return (
    <FormControl>
      <FormControl.Label>Action Menu Label</FormControl.Label>
      <ThemeProvider theme={theme}>
        <SSRProvider>
          <BaseStyles>
            <ActionMenu>
              <TooltipV2 text="Additional context about the menu button" direction="s">
                <ActionMenu.Button>Toggle Menu</ActionMenu.Button>
              </TooltipV2>
              <ActionMenu.Overlay>
                <ActionList>
                  <ActionList.Item>New file</ActionList.Item>
                </ActionList>
              </ActionMenu.Overlay>
            </ActionMenu>
          </BaseStyles>
        </SSRProvider>
      </ThemeProvider>
    </FormControl>
  )
}
  it('MenuButton wrapped by TooltipV2 within FormControl should be labelled by FormControl.Label', async () => {
    const component = HTMLRender(<ExampleWithTooltipV2WithForm />)
    const button = component.getByLabelText('Action Menu Label')
    expect(button).toBeVisible()
    const buttonByRole = component.getByRole('button')
    expect(button.id).toBe(buttonByRole.id)
  })

But I got this error because the FormControl.Label ended up labelling the TooltipV2 instead of the ActionMenu.Button (presumably since the tooltip is wrapping the button).
image

If I try to force the TooltipV2 to act similarly to Tooltip (v1) and wrap the entire ActionMenu

function ExampleWithTooltipV2WithForm(): JSX.Element {
  return (
    <FormControl>
      <FormControl.Label>Action Menu Label</FormControl.Label>
      <ThemeProvider theme={theme}>
        <SSRProvider>
          <BaseStyles>
            <TooltipV2 text="Additional context about the menu button" direction="s">
              <ActionMenu>
                <ActionMenu.Button>Toggle Menu</ActionMenu.Button>
                <ActionMenu.Overlay>
                  <ActionList>
                    <ActionList.Item>New file</ActionList.Item>
                  </ActionList>
                </ActionMenu.Overlay>
              </ActionMenu>
            </TooltipV2>
          </BaseStyles>
        </SSRProvider>
      </ThemeProvider>
    </FormControl>
  )
}

I get this error since that's not how TooltipV2 is supposed to be used.
image

Either way I'm not concerned about not having a test including TooltipV2 since I can't imagine a valid use case for having an ActionMenu within a FormControl being labelled by a FormControl.Label and then also adding a tooltip on top. Furthermore our Primer a11y guidelines advise against using tooltips in most circumstances: https://primer.style/guides/accessibility/tooltip-alternatives#why.

describe('ActionMenu', () => {
behavesAsComponent({
Component: ActionList,
Expand Down Expand Up @@ -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(<ExampleWithForm />)
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(<ExampleWithTooltipWithForm />)
const button = component.getByLabelText('Action Menu Label')
expect(button).toBeVisible()
const buttonByRole = component.getByRole('button')
expect(button.id).toBe(buttonByRole.id)
})
})
Loading