Skip to content
Draft
5 changes: 5 additions & 0 deletions .changeset/composable-trigger-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

ActionMenu, AnchoredOverlay, Tooltip: Add experimental element-based trigger composition through `@primer/react/experimental`, with automatically merged trigger props and refs.
5 changes: 5 additions & 0 deletions .changeset/experimental-tooltip-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Tooltip: Add experimental tooltip-only suppression, controlled and uncontrolled visibility, and separate popup attributes. HTML attributes on the experimental component consistently configure its trigger.
125 changes: 123 additions & 2 deletions packages/react/src/ActionMenu/ActionMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {page, userEvent} from 'vitest/browser'
import {describe, expect, it, vi, beforeEach} from 'vitest'
import {render as HTMLRender, waitFor, act, within} from '@testing-library/react'
import {render as HTMLRender, waitFor, act, within, fireEvent} from '@testing-library/react'
import rtlUserEvent from '@testing-library/user-event'
import type React from 'react'
import {useRef, useState} from 'react'
import {createRef, useRef, useState} from 'react'
import BaseStyles from '../BaseStyles'
import {ActionMenu} from '.'
import {ActionList} from '../ActionList'
Expand All @@ -21,6 +21,127 @@ import type {JSX} from 'react'
import {implementsClassName} from '../utils/testing'
import {createRenderCounter} from '../utils/testing/profiler'
import {FeatureFlags} from '../FeatureFlags'
import {
ActionMenu as ExperimentalActionMenu,
Tooltip as ExperimentalTooltip,
} from '../experimental/TriggerComposition/TriggerComposition'

describe('ActionMenu renderAnchor', () => {
const ActionMenu = ExperimentalActionMenu
const TooltipV2 = ExperimentalTooltip
it.each([false, true])('composes a tooltip and menu on one button with merged refs (flag: %s)', async mergedRefs => {
const anchorRef = createRef<HTMLElement>()
const tooltipRef = vi.fn()
const {unmount} = HTMLRender(
<FeatureFlags flags={{primer_react_merged_forwarded_refs: mergedRefs}}>
<ActionMenu
anchorRef={anchorRef}
renderAnchor={({ref, ...menuProps}) => (
<TooltipV2
ref={node => {
ref(node)
tooltipRef(node)
}}
text="Choose an action"
renderAnchor={tooltipProps => (
<IconButton
{...menuProps}
{...tooltipProps}
icon={SearchIcon}
aria-label="Actions"
aria-labelledby={undefined}
/>
)}
/>
)}
>
<ActionMenu.Overlay>
<ActionList>
<ActionList.Item>New file</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</FeatureFlags>,
)
const button = page.getByRole('button', {name: 'Actions', exact: true})
expect(document.querySelectorAll('button')).toHaveLength(1)
expect(anchorRef.current).toBe(button.element())
expect(tooltipRef).toHaveBeenLastCalledWith(button.element())
const description = button.element().getAttribute('aria-describedby')
expect(document.getElementById(description ?? '')).toHaveTextContent('Choose an action')
await expect.element(button).toHaveAttribute('aria-haspopup', 'true')

await userEvent.click(button)
await expect.element(button).toHaveAttribute('aria-expanded', 'true')
await expect.element(page.getByRole('menuitem', {name: 'New file'})).toHaveFocus()
await userEvent.keyboard('{Escape}')
await expect.element(button).toHaveAttribute('aria-expanded', 'false')
await expect.element(button).toHaveFocus()

await userEvent.keyboard('{ArrowDown}')
await expect.element(page.getByRole('menuitem', {name: 'New file'})).toHaveFocus()
unmount()
expect(anchorRef.current).toBeNull()
expect(tooltipRef).toHaveBeenLastCalledWith(null)
})

it('lets a custom trigger cancel opening before forwarding the event', async () => {
const onOpenChange = vi.fn()
const calls: string[] = []
HTMLRender(
<ActionMenu
onOpenChange={onOpenChange}
renderAnchor={props => (
<Button
{...props}
onClick={event => {
calls.push('consumer')
event.preventDefault()
props.onClick?.(event)
calls.push('forwarded')
}}
onKeyDown={event => {
event.preventDefault()
props.onKeyDown?.(event)
}}
>
Actions
</Button>
)}
>
<ActionMenu.Overlay>Menu contents</ActionMenu.Overlay>
</ActionMenu>,
)
const button = page.getByRole('button', {name: 'Actions', exact: true})
await userEvent.click(button)
expect(calls).toEqual(['consumer', 'forwarded'])
expect(onOpenChange).not.toHaveBeenCalled()
await userEvent.keyboard('{ArrowDown}')
expect(onOpenChange).not.toHaveBeenCalled()
await expect.element(button).toHaveAttribute('aria-expanded', 'false')
})

it('preserves native disabled behavior for a custom trigger', async () => {
const onOpenChange = vi.fn()
HTMLRender(
<ActionMenu
onOpenChange={onOpenChange}
renderAnchor={props => (
<Button {...props} disabled>
Actions
</Button>
)}
>
<ActionMenu.Overlay>Menu contents</ActionMenu.Overlay>
</ActionMenu>,
)
const button = page.getByRole('button', {name: 'Actions', exact: true})
await expect.element(button).toBeDisabled()
fireEvent.click(button.element())
expect(onOpenChange).not.toHaveBeenCalled()
await expect.element(button).toHaveAttribute('aria-expanded', 'false')
})
})

// Mock getAnchoredPosition for feature flag tests
vi.mock('@primer/behaviors', async () => {
Expand Down
47 changes: 40 additions & 7 deletions packages/react/src/ActionMenu/ActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 {useMergedRefs, useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuKeyboardNavigation} from '../hooks'
import {Divider} from '../ActionList/Divider'
import {ActionListContainerContext} from '../ActionList/ActionListContainerContext'
import type {ButtonProps} from '../Button'
Expand All @@ -20,6 +20,8 @@ import {isSlot} from '../utils/is-slot'
import type {FCWithSlotMarker, WithSlotMarker} from '../utils/types/Slots'
import {DialogContext} from '../Dialog/DialogContext'
import useLayoutEffect from '../utils/useIsomorphicLayoutEffect'
import type {AnchorRenderProps} from '../internal/components/RenderAnchor'
import {invariant} from '../utils/invariant'

export type MenuCloseHandler = (
gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab' | 'item-select' | 'arrow-left' | 'close',
Expand All @@ -31,6 +33,7 @@ export type MenuContextProps = Pick<
> & {
onClose?: MenuCloseHandler
isSubmenu?: boolean
isCustomAnchor?: boolean
}
const MenuContext = React.createContext<MenuContextProps>({renderAnchor: null, open: false})

Expand Down Expand Up @@ -81,12 +84,15 @@ const mergeAnchorHandlers = (anchorProps: React.HTMLAttributes<HTMLElement>, but
return mergedAnchorProps
}

const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({
type ActionMenuImplProps = ActionMenuProps & {renderAnchor?: (props: AnchorRenderProps) => React.ReactElement}

export const ActionMenuImpl: FCWithSlotMarker<React.PropsWithChildren<ActionMenuImplProps>> = ({
anchorRef: externalAnchorRef,
open,
onOpenChange,
children,
}: ActionMenuProps) => {
renderAnchor: customRenderAnchor,
}: ActionMenuImplProps) => {
const parentMenuContext = useContext(MenuContext)

const [combinedOpenState, setCombinedOpenState] = useProvidedStateOrCreate(open, onOpenChange, false)
Expand Down Expand Up @@ -114,15 +120,37 @@ const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({
(child.type === MenuButton || child.type === Anchor || isSlot(child, Anchor) || isSlot(child, MenuButton)),
)
const menuButtonChildId = React.isValidElement(menuButtonChild) ? menuButtonChild.props.id : undefined
const hasTooltipAnchor =
customRenderAnchor &&
React.Children.toArray(children).some(
child =>
React.isValidElement(child) &&
(child.type === Tooltip || isSlot(child, Tooltip)) &&
React.isValidElement(child.props.children) &&
(child.props.children.type === MenuButton || isSlot(child.props.children, MenuButton)),
)
invariant(
!customRenderAnchor || (!menuButtonChild && !hasTooltipAnchor),
'Pass either `renderAnchor` or `ActionMenu.Button`/`ActionMenu.Anchor`, not both.',
)

const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
const callbackAnchorRef = useMergedRefs(anchorRef, undefined)
const anchorId = useId(menuButtonChildId)
let renderAnchor: AnchoredOverlayProps['renderAnchor'] = null
const renderCustomAnchor = useMemo(
() =>
customRenderAnchor
? (props: React.HTMLAttributes<HTMLElement>) => customRenderAnchor({...props, ref: callbackAnchorRef})
: null,
[customRenderAnchor, callbackAnchorRef],
)
let renderAnchor: AnchoredOverlayProps['renderAnchor'] = renderCustomAnchor
// 🚨 Hack for good API!
// we strip out Anchor from children and pass it to AnchoredOverlay to render
// with additional props for accessibility
// 🚨 Accounting for Tooltip wrapping ActionMenu.Button or being a direct child of ActionMenu.Anchor.
const contents = React.Children.map(children, child => {
if (customRenderAnchor) return child
// Is ActionMenu.Button wrapped with Tooltip? If this is the case, our anchor is the tooltip's trigger (ActionMenu.Button's grandchild)
if (child.type === Tooltip || isSlot(child, Tooltip)) {
// tooltip trigger
Expand Down Expand Up @@ -184,8 +212,9 @@ const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ({
onOpen,
onClose,
isSubmenu,
isCustomAnchor: Boolean(customRenderAnchor),
}),
[anchorRef, renderAnchor, anchorId, combinedOpenState, onOpen, onClose, isSubmenu],
[anchorRef, renderAnchor, anchorId, combinedOpenState, onOpen, onClose, isSubmenu, customRenderAnchor],
)

return <MenuContext.Provider value={menuContextValue}>{contents}</MenuContext.Provider>
Expand Down Expand Up @@ -295,6 +324,7 @@ const Overlay: FCWithSlotMarker<React.PropsWithChildren<MenuOverlayProps>> = ({
onOpen,
onClose,
isSubmenu = false,
isCustomAnchor = false,
} = React.useContext(MenuContext) as MandateProps<MenuContextProps, 'anchorRef'>

const containerRef = React.useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -326,10 +356,12 @@ const Overlay: FCWithSlotMarker<React.PropsWithChildren<MenuOverlayProps>> = ({
const [anchorAriaLabelledby, setAnchorAriaLabelledby] = useState<null | string>(null)
useLayoutEffect(() => {
if (open) {
setAnchorAriaLabelledby(anchorRef.current?.getAttribute('aria-labelledby') ?? null)
setAnchorAriaLabelledby(
anchorRef.current?.getAttribute('aria-labelledby') || (isCustomAnchor ? anchorRef.current?.id : null) || null,
)
setAnchorLabelReady(true)
}
}, [anchorRef, open])
}, [anchorRef, open, isCustomAnchor])

const afterSelect = useCallback(() => onClose?.('item-select'), [onClose])

Expand Down Expand Up @@ -381,6 +413,7 @@ const Overlay: FCWithSlotMarker<React.PropsWithChildren<MenuOverlayProps>> = ({
)
}

const Menu: FCWithSlotMarker<React.PropsWithChildren<ActionMenuProps>> = ActionMenuImpl
Menu.displayName = 'ActionMenu'

MenuButton.__SLOT__ = Symbol('ActionMenu.Button')
Expand Down
23 changes: 23 additions & 0 deletions packages/react/src/ActionMenu/ActionMenu.types.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {ActionMenu} from '.'
import {ActionMenu as ExperimentalActionMenu, Tooltip} from '../experimental/TriggerComposition/TriggerComposition'
import {Button} from '../Button'

export function actionButtonWithoutProps() {
return <ActionMenu.Button />
Expand All @@ -25,3 +27,24 @@ export function actionOverlayWithInvalidPreventFocusOnClose() {
// @ts-expect-error preventFocusOnClose must be a boolean
return <ActionMenu.Overlay preventFocusOnClose="true">Menu contents</ActionMenu.Overlay>
}

export function actionMenuWithComposedTrigger() {
const ActionMenu = ExperimentalActionMenu
return (
<ActionMenu
renderAnchor={({ref, ...menuProps}) => (
<Tooltip
ref={ref}
text="Additional context"
renderAnchor={tooltipProps => (
<Button {...menuProps} {...tooltipProps}>
Actions
</Button>
)}
/>
)}
>
<ActionMenu.Overlay>Menu contents</ActionMenu.Overlay>
</ActionMenu>
)
}
24 changes: 13 additions & 11 deletions packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -406,17 +406,19 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
return (
<>
{renderAnchor &&
// eslint-disable-next-line react-hooks/refs
renderAnchor({
ref: anchorRef,
id: anchorId,
'aria-haspopup': 'true',
'aria-expanded': open,
tabIndex: 0,
onClick: onAnchorClick,
onKeyDown: onAnchorKeyDown,
...popoverTargetProps,
})}
renderAnchor(
// eslint-disable-next-line react-hooks/refs -- the callback forwards the object ref to an element
{
ref: anchorRef,
id: anchorId,
'aria-haspopup': 'true',
'aria-expanded': open,
tabIndex: 0,
onClick: onAnchorClick,
onKeyDown: onAnchorKeyDown,
...popoverTargetProps,
},
)}
{open ? (
<Overlay
returnFocusRef={anchorRef}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import {InfoIcon} from '@primer/octicons-react'
import {AnchoredOverlay} from '.'
import {AnchoredOverlay, type AnchoredOverlayProps} from '.'
import {IconButton} from '../Button'
import {
AnchoredOverlay as ExperimentalAnchoredOverlay,
Tooltip,
} from '../experimental/TriggerComposition/TriggerComposition'

export function itemWithIconElements() {
return (
Expand All @@ -9,3 +13,18 @@ export function itemWithIconElements() {
</AnchoredOverlay>
)
}

export function itemWithTooltip() {
return (
<ExperimentalAnchoredOverlay
open={false}
renderAnchor={<Tooltip text="Additional context" renderAnchor={<button type="button">Info</button>} />}
>
<p>GitHub</p>
</ExperimentalAnchoredOverlay>
)
}

export function invokeExistingAnchor(renderAnchor: AnchoredOverlayProps['renderAnchor']) {
return renderAnchor?.({'aria-expanded': false})
}
Loading
Loading