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..6aae1f41c2b 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' @@ -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(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') + }) + + it('does not write ARIA to a non-interactive detached anchor (
or plain )', async () => { + function NonInteractiveAnchorComponent({tag}: {tag: 'div' | 'input'}) { + const [open, setOpen] = useState(false) + const anchorRef = useRef(null) + + return ( + + {tag === 'div' ? ( +
} + data-testid="anchor" + onClick={() => setOpen(isOpen => !isOpen)} + > + Detached div anchor +
+ ) : ( + } + data-testid="anchor" + onClick={() => setOpen(isOpen => !isOpen)} + readOnly + value="Detached input anchor" + /> + )} + setOpen(true)} + onClose={() => setOpen(false)} + renderAnchor={null} + anchorRef={anchorRef} + > + + +
+ ) + } + + for (const tag of ['div', 'input'] as const) { + const {baseElement, unmount} = render() + 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(null) + + return ( + + setOpen(isOpen => !isOpen)} + /> + setOpen(true)} + onClose={() => setOpen(false)} + renderAnchor={null} + anchorRef={anchorRef} + > + + + + ) + } + + const {baseElement} = render() + 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 diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index a0c9d6a1da8..3f340a2a36d 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -18,6 +18,44 @@ import {useFeatureFlag} from '../FeatureFlags' import {widthMap} from '../Overlay/constants' import {reactMajorVersion} from '../utils/environment' +type AnchorHasPopup = Exclude +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 `
` 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', + '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. @@ -116,6 +154,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-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 @@ -408,7 +486,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', '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