Skip to content
Open
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/anchored-overlay-detached-anchor-aria.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

AnchoredOverlay and SelectPanel: add trigger ARIA state for detached anchors
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,13 @@
"description": "If true, a close button will be displayed in the top right corner of the overlay when on fullscreen mode.",
"defaultValue": "true"
},
{
"name": "anchorHasPopup",
"type": "'true' | 'dialog' | 'menu' | 'listbox' | 'tree' | 'grid'",
"required": false,
"description": "Indicates the type of popup opened by the anchor. Defaults to `'true'`, which is equivalent to `'menu'` in ARIA.",
"defaultValue": "'true'"
},

@siddharthkp siddharthkp Jul 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have thoughts on the API, but it's a a good to have for the bug fix we are doing here and I don't want to block the bug fix.

Can you split this PR into 2:

  1. the first PR is the bug fix that copies current renderAnchor behavior (value: true)
  2. second PR lets you customize the value of aria-haspopup, so that we can discuss it there.

Thanks!

{
"name": "closeButtonProps",
"type": "Partial<ButtonProps>",
Expand Down
190 changes: 190 additions & 0 deletions packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {describe, expect, it, vi} from 'vitest'
import {act, fireEvent, render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {AnchoredOverlay} from '../AnchoredOverlay'
import type {AnchorHasPopup} from '../AnchoredOverlay'
import {Button} from '../Button'
import BaseStyles from '../BaseStyles'
import type {AnchorPosition} from '@primer/behaviors'
Expand Down Expand Up @@ -245,6 +246,195 @@ describe.each([true, false])(
},
)

describe('AnchoredOverlay anchor ARIA', () => {
function DetachedOverlayTestComponent({
initiallyOpen = false,
anchorHasPopup = 'true',
consumerHasPopup,
}: {
initiallyOpen?: boolean
anchorHasPopup?: AnchorHasPopup
consumerHasPopup?: AnchorHasPopup
}) {
const [open, setOpen] = useState(initiallyOpen)
const anchorRef = useRef<HTMLButtonElement>(null)

return (
<BaseStyles>
<button
type="button"
ref={anchorRef}
data-testid="detached-anchor"
onClick={() => setOpen(isOpen => !isOpen)}
{...(consumerHasPopup ? {'aria-haspopup': consumerHasPopup} : {})}
>
Detached anchor
</button>
<AnchoredOverlay
open={open}
onOpen={() => setOpen(true)}
onClose={() => setOpen(false)}
renderAnchor={null}
anchorRef={anchorRef}
anchorHasPopup={anchorHasPopup}
>
<button type="button">Focusable Child</button>
</AnchoredOverlay>
</BaseStyles>
)
}

it('sets aria-haspopup and aria-expanded on a detached anchor when closed', async () => {
const {baseElement} = render(<DetachedOverlayTestComponent />)
await act(async () => {})

const anchor = baseElement.querySelector('[data-testid="detached-anchor"]')
expect(anchor).toHaveAttribute('aria-haspopup', 'true')
expect(anchor).toHaveAttribute('aria-expanded', 'false')
})

it('reflects aria-expanded on a detached anchor when opened and closed', async () => {
const {baseElement} = render(<DetachedOverlayTestComponent />)
await act(async () => {})

const anchor = baseElement.querySelector('[data-testid="detached-anchor"]') as HTMLElement
expect(anchor).toHaveAttribute('aria-expanded', 'false')

await userEvent.click(anchor)
expect(anchor).toHaveAttribute('aria-expanded', 'true')

await userEvent.click(anchor)
expect(anchor).toHaveAttribute('aria-expanded', 'false')
})

it('does not overwrite aria-haspopup supplied by a detached anchor consumer', async () => {
const {baseElement} = render(<DetachedOverlayTestComponent consumerHasPopup="listbox" />)
await act(async () => {})

const anchor = baseElement.querySelector('[data-testid="detached-anchor"]')
expect(anchor).toHaveAttribute('aria-haspopup', 'listbox')
expect(anchor).toHaveAttribute('aria-expanded', 'false')
})

it('reflects anchorHasPopup on the rendered anchor path', () => {
const {baseElement} = render(
<BaseStyles>
<AnchoredOverlay
open={false}
onOpen={() => {}}
onClose={() => {}}
anchorHasPopup="dialog"
renderAnchor={props => (
<button type="button" {...props}>
Anchor Button
</button>
)}
>
<button type="button">Focusable Child</button>
</AnchoredOverlay>
</BaseStyles>,
)

expect(baseElement.querySelector('button')).toHaveAttribute('aria-haspopup', 'dialog')
})

it('does not leave aria-expanded="true" on a detached anchor after close', async () => {
const {baseElement} = render(<DetachedOverlayTestComponent initiallyOpen={true} />)
await act(async () => {})

const anchor = baseElement.querySelector('[data-testid="detached-anchor"]') as HTMLElement
expect(anchor).toHaveAttribute('aria-expanded', 'true')

await userEvent.click(anchor)
expect(anchor).not.toHaveAttribute('aria-expanded', 'true')
})

it('does not write ARIA to a non-interactive detached anchor (<div> or plain <input>)', async () => {
function NonInteractiveAnchorComponent({tag}: {tag: 'div' | 'input'}) {
const [open, setOpen] = useState(false)
const anchorRef = useRef<HTMLElement>(null)

return (
<BaseStyles>
{tag === 'div' ? (
<div
ref={anchorRef as React.RefObject<HTMLDivElement>}
data-testid="anchor"
onClick={() => setOpen(isOpen => !isOpen)}
>
Detached div anchor
</div>
) : (
<input
ref={anchorRef as React.RefObject<HTMLInputElement>}
data-testid="anchor"
onClick={() => setOpen(isOpen => !isOpen)}
readOnly
value="Detached input anchor"
/>
)}
<AnchoredOverlay
open={open}
onOpen={() => setOpen(true)}
onClose={() => setOpen(false)}
renderAnchor={null}
anchorRef={anchorRef}
>
<button type="button">Focusable Child</button>
</AnchoredOverlay>
</BaseStyles>
)
}

for (const tag of ['div', 'input'] as const) {
const {baseElement, unmount} = render(<NonInteractiveAnchorComponent tag={tag} />)
await act(async () => {})

const anchor = baseElement.querySelector('[data-testid="anchor"]')
expect(anchor).not.toHaveAttribute('aria-haspopup')
expect(anchor).not.toHaveAttribute('aria-expanded')
unmount()
}
})

it('writes ARIA to a detached anchor with a popup-supporting role (e.g. role="combobox")', async () => {
function ComboboxAnchorComponent() {
const [open, setOpen] = useState(false)
const anchorRef = useRef<HTMLInputElement>(null)

return (
<BaseStyles>
<input
ref={anchorRef}
data-testid="anchor"
role="combobox"
aria-controls="overlay"
readOnly
value="Combobox anchor"
onClick={() => setOpen(isOpen => !isOpen)}
/>
<AnchoredOverlay
open={open}
onOpen={() => setOpen(true)}
onClose={() => setOpen(false)}
renderAnchor={null}
anchorRef={anchorRef}
>
<button type="button">Focusable Child</button>
</AnchoredOverlay>
</BaseStyles>
)
}

const {baseElement} = render(<ComboboxAnchorComponent />)
await act(async () => {})

const anchor = baseElement.querySelector('[data-testid="anchor"]')
expect(anchor).toHaveAttribute('aria-haspopup', 'true')
expect(anchor).toHaveAttribute('aria-expanded', 'false')
})
})

describe('AnchoredOverlay scroll/resize cascade', () => {
it('does not re-render after close when window scrolls (closed-overlay listeners detached)', async () => {
// Before the `enabled: open` fix, useAnchoredPosition kept its scroll
Expand Down
80 changes: 79 additions & 1 deletion packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,44 @@ import {useFeatureFlag} from '../FeatureFlags'
import {widthMap} from '../Overlay/constants'
import {reactMajorVersion} from '../utils/environment'

type AnchorHasPopup = Exclude<React.AriaAttributes['aria-haspopup'], boolean | 'false' | undefined>
export type {AnchorHasPopup}

// `aria-haspopup` and `aria-expanded` describe a button-like trigger that opens
// a popup, and are only valid on elements whose role supports both. In
// detached-anchor mode the consumer owns the anchor element, so guard the
// imperative ARIA writes: if the ref points at an element that can't legally
// carry them (e.g. a wrapper `<div>` or a plain `<input>`/textbox), writing them
// produces invalid ARIA (axe `aria-allowed-attr`). In that case the consumer
// should point the ref at the real trigger (or give it an appropriate role), and
// we skip rather than emit a violation.
const rolesSupportingPopupAria = new Set([
'application',
'button',
'combobox',
'gridcell',
'link',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'tab',
'treeitem',
])

function anchorSupportsPopupAria(node: HTMLElement): boolean {
const explicitRole = node.getAttribute('role')
if (explicitRole) return rolesSupportingPopupAria.has(explicitRole)
switch (node.tagName) {
case 'BUTTON':
case 'SUMMARY':
return true
case 'A':
return node.hasAttribute('href')
default:
return false
}
}

interface AnchoredOverlayPropsWithAnchor {
/**
* A custom function component used to render the anchor element.
Expand Down Expand Up @@ -116,6 +154,10 @@ interface AnchoredOverlayBaseProps extends Pick<OverlayProps, 'height' | 'width'
* Optional prop to display a close button in the overlay.
*/
displayCloseButton?: boolean
/**
* Indicates the type of popup opened by the anchor. Defaults to `'true'`, which is equivalent to `'menu'` in ARIA.
*/
anchorHasPopup?: AnchorHasPopup
/**
* Props to be spread on the close button in the overlay.
*/
Expand Down Expand Up @@ -178,6 +220,7 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
preventOverflow = true,
onPositionChange,
displayCloseButton = true,
anchorHasPopup = 'true',
closeButtonProps = defaultCloseButtonProps,
renderAs = 'portal',
cssAnchorPositioningSettings,
Expand Down Expand Up @@ -314,6 +357,41 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
}
}, [cssAnchorPositioning, anchorElement, anchorName])

useEffect(() => {
// Read the node from the ref (available once committed) rather than the
// anchorElement state, which lags a render behind for a detached anchor and
// would leave the collapsed trigger without aria-haspopup until first opened.
// aria-haspopup is stable for the component's lifetime, so this effect is
// kept separate from `open` to avoid rewriting the attribute on every toggle.
const node = anchorRef.current ?? anchorElement
if (renderAnchor !== null || !node) return
if (!anchorSupportsPopupAria(node)) return // avoid invalid ARIA on non-interactive anchors
if (node.hasAttribute('aria-haspopup')) return // don't clobber a consumer value

node.setAttribute('aria-haspopup', anchorHasPopup)

return () => {
if (node.getAttribute('aria-haspopup') === anchorHasPopup) {
node.removeAttribute('aria-haspopup')
}
}
}, [renderAnchor, anchorRef, anchorElement, anchorHasPopup])

useEffect(() => {
const node = anchorRef.current ?? anchorElement
if (renderAnchor !== null || !node) return
if (!anchorSupportsPopupAria(node)) return // avoid invalid ARIA on non-interactive anchors

const expandedValue = String(open)
node.setAttribute('aria-expanded', expandedValue)

return () => {
if (node.getAttribute('aria-expanded') === expandedValue) {
node.removeAttribute('aria-expanded')
}
}
}, [renderAnchor, anchorRef, anchorElement, open])

useEffect(() => {
if (!cssAnchorPositioning || !anchorElement) return

Expand Down Expand Up @@ -408,7 +486,7 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr
renderAnchor({
ref: anchorRef,
id: anchorId,
'aria-haspopup': 'true',
'aria-haspopup': anchorHasPopup,
'aria-expanded': open,
tabIndex: 0,
onClick: onAnchorClick,
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/AnchoredOverlay/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export {AnchoredOverlay} from './AnchoredOverlay'
export type {AnchoredOverlayProps} from './AnchoredOverlay'
export type {AnchoredOverlayProps, AnchorHasPopup} from './AnchoredOverlay'
37 changes: 37 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,43 @@ for (const usingRemoveActiveDescendant of [false, true]) {
expect(trigger).toHaveAttribute('aria-expanded', 'false')
})

it('sets aria-haspopup="true" on a detached anchor', async () => {
function DetachedSelectPanel() {
const [selected, setSelected] = React.useState<SelectPanelProps['items']>([])
const [filter, setFilter] = React.useState('')
const [open, setOpen] = React.useState(false)
const anchorRef = React.useRef<HTMLButtonElement>(null)

return (
<>
<button type="button" ref={anchorRef} onClick={() => setOpen(isOpen => !isOpen)}>
Detached select anchor
</button>
<SelectPanel
title="test title"
items={items}
selected={selected}
onSelectedChange={setSelected}
filterValue={filter}
onFilterChange={setFilter}
open={open}
onOpenChange={setOpen}
renderAnchor={null}
anchorRef={anchorRef}
/>
</>
)
}

render(<DetachedSelectPanel />)

const trigger = screen.getByRole('button', {name: 'Detached select anchor'})
await waitFor(() => {
expect(trigger).toHaveAttribute('aria-haspopup', 'true')
})
expect(trigger).toHaveAttribute('aria-expanded', 'false')
})

it('should call onActiveDescendantChanged when using keyboard while focusing on an item', async () => {
const user = userEvent.setup()
// jest function
Expand Down
Loading