From c357cef76f8a24475f8bcad32c1bff95bde0a6ef Mon Sep 17 00:00:00 2001 From: Josh Farrant Date: Wed, 1 Jul 2026 09:51:19 +0100 Subject: [PATCH 1/5] Add trigger ARIA state for detached AnchoredOverlay/SelectPanel anchors In detached-anchor mode (renderAnchor={null}) the consumer owns the trigger element, so AnchoredOverlay never rendered the aria-haspopup/aria-expanded it applies in the renderAnchor path. Screen reader users had no indication the trigger opened a popup, nor its expanded state. (github/primer#6776) - AnchoredOverlay: imperatively reflect aria-haspopup/aria-expanded onto the detached anchor node, mirroring the existing anchor-name effect. The effect reads anchorRef.current (available after commit) so the collapsed trigger is labelled on mount, guards against clobbering a consumer-supplied aria-haspopup, and cleans up on close/unmount. - Add an anchorHasPopup prop ('true' | 'dialog' | 'menu' | 'listbox' | 'tree' | 'grid', default 'true') so the popup role can be described accurately; applied in both the renderAnchor and detached paths. - SelectPanel passes anchorHasPopup="dialog" to match its role="dialog" popup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../anchored-overlay-detached-anchor-aria.md | 5 + .../AnchoredOverlay/AnchoredOverlay.docs.json | 7 ++ .../AnchoredOverlay/AnchoredOverlay.test.tsx | 104 ++++++++++++++++++ .../src/AnchoredOverlay/AnchoredOverlay.tsx | 35 +++++- .../src/SelectPanel/SelectPanel.test.tsx | 39 ++++++- .../react/src/SelectPanel/SelectPanel.tsx | 1 + 6 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 .changeset/anchored-overlay-detached-anchor-aria.md diff --git a/.changeset/anchored-overlay-detached-anchor-aria.md b/.changeset/anchored-overlay-detached-anchor-aria.md new file mode 100644 index 00000000000..ffb66cd2c86 --- /dev/null +++ b/.changeset/anchored-overlay-detached-anchor-aria.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +AnchoredOverlay and SelectPanel: add trigger ARIA state for detached anchors diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.docs.json b/packages/react/src/AnchoredOverlay/AnchoredOverlay.docs.json index 75fe3cf0487..51a111cc026 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.docs.json +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.docs.json @@ -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'" + }, { "name": "closeButtonProps", "type": "Partial", diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx index d5e6b4179bf..62de41f7a59 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx @@ -245,6 +245,110 @@ describe.each([true, false])( }, ) +describe('AnchoredOverlay anchor ARIA', () => { + function DetachedOverlayTestComponent({ + initiallyOpen = false, + anchorHasPopup = 'true', + consumerHasPopup, + }: { + initiallyOpen?: boolean + anchorHasPopup?: 'true' | 'dialog' | 'menu' | 'listbox' | 'tree' | 'grid' + consumerHasPopup?: 'true' | 'dialog' | 'menu' | 'listbox' | 'tree' | 'grid' + }) { + const [open, setOpen] = useState(initiallyOpen) + const anchorRef = useRef(null) + + return ( + + + setOpen(true)} + onClose={() => setOpen(false)} + renderAnchor={null} + anchorRef={anchorRef} + anchorHasPopup={anchorHasPopup} + > + + + + ) + } + + it('sets aria-haspopup and aria-expanded on a detached anchor when closed', async () => { + const {baseElement} = render() + 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() + 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() + 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( + + {}} + onClose={() => {}} + anchorHasPopup="dialog" + renderAnchor={props => ( + + )} + > + + + , + ) + + 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() + 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') + }) +}) + 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 diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index a0c9d6a1da8..abc638c9202 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -18,6 +18,8 @@ import {useFeatureFlag} from '../FeatureFlags' import {widthMap} from '../Overlay/constants' import {reactMajorVersion} from '../utils/environment' +type AnchorHasPopup = 'true' | 'dialog' | 'menu' | 'listbox' | 'tree' | 'grid' + interface AnchoredOverlayPropsWithAnchor { /** * A custom function component used to render the anchor element. @@ -116,6 +118,10 @@ interface AnchoredOverlayBaseProps extends Pick { + // 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 until first opened. + const node = anchorRef.current ?? anchorElement + if (renderAnchor !== null || !node) return + + const addedHasPopup = !node.hasAttribute('aria-haspopup') + const expandedValue = String(open) + + if (addedHasPopup) { + node.setAttribute('aria-haspopup', anchorHasPopup) + } + node.setAttribute('aria-expanded', expandedValue) + + return () => { + if (addedHasPopup && node.getAttribute('aria-haspopup') === anchorHasPopup) { + node.removeAttribute('aria-haspopup') + } + if (node.getAttribute('aria-expanded') === expandedValue) { + node.removeAttribute('aria-expanded') + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [renderAnchor, anchorElement, anchorHasPopup, open]) + useEffect(() => { if (!cssAnchorPositioning || !anchorElement) return @@ -408,7 +441,7 @@ export const AnchoredOverlay: React.FC { + function DetachedSelectPanel() { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) + const anchorRef = React.useRef(null) + + return ( + <> + + + + ) + } + + render() + + const trigger = screen.getByRole('button', {name: 'Detached select anchor'}) + await waitFor(() => { + expect(trigger).toHaveAttribute('aria-haspopup', 'dialog') + }) expect(trigger).toHaveAttribute('aria-expanded', 'false') }) diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index d818724c193..e342d6669bf 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -893,6 +893,7 @@ function Panel({ open={open} onOpen={onOpen} onClose={onClose} + anchorHasPopup="dialog" overlayProps={mergedOverlayProps} focusTrapSettings={focusTrapSettings} focusZoneSettings={focusZoneSettings} From 491679e08b59f6b2ca87435f17d916ac846dde8d Mon Sep 17 00:00:00 2001 From: Josh Farrant Date: Wed, 1 Jul 2026 10:14:46 +0100 Subject: [PATCH 2/5] Address review: split ARIA effects and derive anchorHasPopup type - Derive AnchorHasPopup from React.AriaAttributes['aria-haspopup'] (excluding boolean/'false') instead of hand-maintaining a parallel union, and export it so consumers and tests stay in sync. - Split the detached-anchor ARIA into two effects: aria-haspopup (stable, no longer keyed on `open`, so it isn't removed/re-added on every toggle) and aria-expanded (reflects `open`). - Reference the exported AnchorHasPopup type in tests instead of duplicating the union. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AnchoredOverlay/AnchoredOverlay.test.tsx | 5 ++-- .../src/AnchoredOverlay/AnchoredOverlay.tsx | 30 ++++++++++++------- packages/react/src/AnchoredOverlay/index.ts | 2 +- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx index 62de41f7a59..a25c9656869 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx @@ -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' @@ -252,8 +253,8 @@ describe('AnchoredOverlay anchor ARIA', () => { consumerHasPopup, }: { initiallyOpen?: boolean - anchorHasPopup?: 'true' | 'dialog' | 'menu' | 'listbox' | 'tree' | 'grid' - consumerHasPopup?: 'true' | 'dialog' | 'menu' | 'listbox' | 'tree' | 'grid' + anchorHasPopup?: AnchorHasPopup + consumerHasPopup?: AnchorHasPopup }) { const [open, setOpen] = useState(initiallyOpen) const anchorRef = useRef(null) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index abc638c9202..374c5474777 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -18,7 +18,8 @@ import {useFeatureFlag} from '../FeatureFlags' import {widthMap} from '../Overlay/constants' import {reactMajorVersion} from '../utils/environment' -type AnchorHasPopup = 'true' | 'dialog' | 'menu' | 'listbox' | 'tree' | 'grid' +type AnchorHasPopup = Exclude +export type {AnchorHasPopup} interface AnchoredOverlayPropsWithAnchor { /** @@ -324,28 +325,37 @@ export const AnchoredOverlay: React.FC { // 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 until first opened. + // 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 (node.hasAttribute('aria-haspopup')) return // don't clobber a consumer value - const addedHasPopup = !node.hasAttribute('aria-haspopup') - const expandedValue = String(open) + node.setAttribute('aria-haspopup', anchorHasPopup) - if (addedHasPopup) { - node.setAttribute('aria-haspopup', anchorHasPopup) + return () => { + if (node.getAttribute('aria-haspopup') === anchorHasPopup) { + node.removeAttribute('aria-haspopup') + } } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [renderAnchor, anchorElement, anchorHasPopup]) + + useEffect(() => { + const node = anchorRef.current ?? anchorElement + if (renderAnchor !== null || !node) return + + const expandedValue = String(open) node.setAttribute('aria-expanded', expandedValue) return () => { - if (addedHasPopup && node.getAttribute('aria-haspopup') === anchorHasPopup) { - node.removeAttribute('aria-haspopup') - } if (node.getAttribute('aria-expanded') === expandedValue) { node.removeAttribute('aria-expanded') } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [renderAnchor, anchorElement, anchorHasPopup, open]) + }, [renderAnchor, anchorElement, open]) useEffect(() => { if (!cssAnchorPositioning || !anchorElement) return diff --git a/packages/react/src/AnchoredOverlay/index.ts b/packages/react/src/AnchoredOverlay/index.ts index 0a89ba09da0..3acca71015f 100644 --- a/packages/react/src/AnchoredOverlay/index.ts +++ b/packages/react/src/AnchoredOverlay/index.ts @@ -1,2 +1,2 @@ export {AnchoredOverlay} from './AnchoredOverlay' -export type {AnchoredOverlayProps} from './AnchoredOverlay' +export type {AnchoredOverlayProps, AnchorHasPopup} from './AnchoredOverlay' From 98484d956213e17b0114dd56ed3a2455ea103e7d Mon Sep 17 00:00:00 2001 From: Josh Farrant Date: Wed, 1 Jul 2026 10:46:14 +0100 Subject: [PATCH 3/5] Address review: track anchorRef in detached ARIA effect deps Add anchorRef to the dependency arrays of the detached aria-haspopup and aria-expanded effects and drop the exhaustive-deps suppressions. anchorRef is stable in the common case (so no extra re-runs), but including it keeps the effects honest and ensures they re-run if a consumer swaps the anchorRef prop to a new RefObject. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 374c5474777..8dc95d1f021 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -339,8 +339,7 @@ export const AnchoredOverlay: React.FC { const node = anchorRef.current ?? anchorElement @@ -354,8 +353,7 @@ export const AnchoredOverlay: React.FC { if (!cssAnchorPositioning || !anchorElement) return From 8b63213c5e22524442b5b2ab7f3764ed8fcc42df Mon Sep 17 00:00:00 2001 From: Josh Farrant Date: Wed, 1 Jul 2026 16:03:00 +0100 Subject: [PATCH 4/5] Fix integration regressions: guard detached ARIA to interactive anchors, revert SelectPanel dialog The github-ui integration test surfaced two regressions: 1. axe aria-allowed-attr violations: consumers using detached mode with the anchorRef pointing at a non-interactive element (e.g. a wrapper
) had aria-haspopup/aria-expanded imperatively written onto them, which is invalid ARIA. Guard the writes to only apply when the anchor element's role supports these attributes (native interactive elements or an appropriate explicit role). 2. SelectPanel changed aria-haspopup from 'true' to 'dialog' for ALL anchors, breaking downstream expectations. Revert to the existing 'true' default to keep the fix scoped to the detached-anchor gap #6776 describes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AnchoredOverlay/AnchoredOverlay.test.tsx | 31 +++++++++++++ .../src/AnchoredOverlay/AnchoredOverlay.tsx | 44 +++++++++++++++++++ .../src/SelectPanel/SelectPanel.test.tsx | 6 +-- .../react/src/SelectPanel/SelectPanel.tsx | 1 - 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx index a25c9656869..ce564ee9bf5 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx @@ -348,6 +348,37 @@ describe('AnchoredOverlay anchor ARIA', () => { await userEvent.click(anchor) expect(anchor).not.toHaveAttribute('aria-expanded', 'true') }) + + it('does not write ARIA to a non-interactive (e.g.
) detached anchor', async () => { + function DivAnchorComponent() { + const [open, setOpen] = useState(false) + const anchorRef = useRef(null) + + return ( + +
setOpen(isOpen => !isOpen)}> + Detached div anchor +
+ setOpen(true)} + onClose={() => setOpen(false)} + renderAnchor={null} + anchorRef={anchorRef} + > + + +
+ ) + } + + const {baseElement} = render() + await act(async () => {}) + + const anchor = baseElement.querySelector('[data-testid="div-anchor"]') + expect(anchor).not.toHaveAttribute('aria-haspopup') + expect(anchor).not.toHaveAttribute('aria-expanded') + }) }) describe('AnchoredOverlay scroll/resize cascade', () => { diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 8dc95d1f021..46f81c49ffb 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -21,6 +21,48 @@ import {reactMajorVersion} from '../utils/environment' type AnchorHasPopup = Exclude export type {AnchorHasPopup} +// `aria-haspopup` and `aria-expanded` are only valid on elements whose role +// supports them. In detached-anchor mode the consumer owns the anchor element, +// so guard the imperative ARIA writes: if the ref points at a non-interactive +// element (e.g. a wrapper `
`), writing these attributes produces invalid +// ARIA (axe `aria-allowed-attr`). In that case the consumer should point the +// ref at the real trigger, and we skip rather than emit a violation. +const rolesSupportingPopupAria = new Set([ + 'application', + 'button', + 'checkbox', + 'columnheader', + 'combobox', + 'gridcell', + 'link', + 'menuitem', + 'menuitemcheckbox', + 'menuitemradio', + 'row', + 'rowheader', + 'switch', + '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': + case 'SELECT': + case 'TEXTAREA': + return true + case 'A': + return node.hasAttribute('href') + case 'INPUT': + return (node as HTMLInputElement).type !== 'hidden' + default: + return false + } +} + interface AnchoredOverlayPropsWithAnchor { /** * A custom function component used to render the anchor element. @@ -330,6 +372,7 @@ export const AnchoredOverlay: React.FC { 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) diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index c86d1b7983f..2147fcb8d6d 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -110,11 +110,11 @@ for (const usingRemoveActiveDescendant of [false, true]) { const trigger = screen.getByRole('button', { name: 'Select items', }) - expect(trigger).toHaveAttribute('aria-haspopup', 'dialog') + expect(trigger).toHaveAttribute('aria-haspopup', 'true') expect(trigger).toHaveAttribute('aria-expanded', 'false') }) - it('sets aria-haspopup="dialog" on a detached anchor', async () => { + it('sets aria-haspopup="true" on a detached anchor', async () => { function DetachedSelectPanel() { const [selected, setSelected] = React.useState([]) const [filter, setFilter] = React.useState('') @@ -146,7 +146,7 @@ for (const usingRemoveActiveDescendant of [false, true]) { const trigger = screen.getByRole('button', {name: 'Detached select anchor'}) await waitFor(() => { - expect(trigger).toHaveAttribute('aria-haspopup', 'dialog') + expect(trigger).toHaveAttribute('aria-haspopup', 'true') }) expect(trigger).toHaveAttribute('aria-expanded', 'false') }) diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index e342d6669bf..d818724c193 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -893,7 +893,6 @@ function Panel({ open={open} onOpen={onOpen} onClose={onClose} - anchorHasPopup="dialog" overlayProps={mergedOverlayProps} focusTrapSettings={focusTrapSettings} focusZoneSettings={focusZoneSettings} From 04c6a90f18f4561bddd661320ac258a34c744801 Mon Sep 17 00:00:00 2001 From: Josh Farrant Date: Wed, 1 Jul 2026 17:00:18 +0100 Subject: [PATCH 5/5] Tighten detached ARIA guard to button-like anchors only The integration test's remaining failure was DatePicker's 'Single Input Anchor', which uses a plain (role textbox) as the detached anchor. role=textbox doesn't support aria-expanded, so writing it is still an axe aria-allowed-attr violation. Restrict the imperative write to genuinely button-like triggers: native + + + ) + } + + const {baseElement} = render() await act(async () => {}) - const anchor = baseElement.querySelector('[data-testid="div-anchor"]') - expect(anchor).not.toHaveAttribute('aria-haspopup') - expect(anchor).not.toHaveAttribute('aria-expanded') + const anchor = baseElement.querySelector('[data-testid="anchor"]') + expect(anchor).toHaveAttribute('aria-haspopup', 'true') + expect(anchor).toHaveAttribute('aria-expanded', 'false') }) }) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index 46f81c49ffb..3f340a2a36d 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -21,26 +21,23 @@ import {reactMajorVersion} from '../utils/environment' type AnchorHasPopup = Exclude export type {AnchorHasPopup} -// `aria-haspopup` and `aria-expanded` are only valid on elements whose role -// supports them. In detached-anchor mode the consumer owns the anchor element, -// so guard the imperative ARIA writes: if the ref points at a non-interactive -// element (e.g. a wrapper `
`), writing these attributes produces invalid -// ARIA (axe `aria-allowed-attr`). In that case the consumer should point the -// ref at the real trigger, and we skip rather than emit a violation. +// `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 `
` or a plain ``/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', - 'checkbox', - 'columnheader', 'combobox', 'gridcell', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', - 'row', - 'rowheader', - 'switch', 'tab', 'treeitem', ]) @@ -51,13 +48,9 @@ function anchorSupportsPopupAria(node: HTMLElement): boolean { switch (node.tagName) { case 'BUTTON': case 'SUMMARY': - case 'SELECT': - case 'TEXTAREA': return true case 'A': return node.hasAttribute('href') - case 'INPUT': - return (node as HTMLInputElement).type !== 'hidden' default: return false }