diff --git a/change/@fluentui-react-headless-components-preview-popover-a11y-39583.json b/change/@fluentui-react-headless-components-preview-popover-a11y-39583.json new file mode 100644 index 0000000000000..970627872c578 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-popover-a11y-39583.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: add aria-controls to PopoverTrigger and name the Default story surface", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx index 83d6cee83dc06..53085253ea7a3 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx @@ -335,6 +335,36 @@ describe('Popover', () => { }); }); + describe('Focus on open', () => { + it('browser focuses a text-only surface on open', () => { + mount( + + + Trigger + + Text only + , + ); + cy.get('[data-testid=trigger]').focus().realPress('Enter'); + cy.get('[data-testid=surface]').should('have.focus'); + }); + + it('browser focuses the first focusable element inside the surface on open', () => { + mount( + + + Trigger + + + Inner + + , + ); + cy.get('[data-testid=trigger]').focus().realPress('Enter'); + cy.get('[data-testid=inner]').should('have.focus'); + }); + }); + describe('Focus restoration', () => { it('should restore focus to trigger on close', () => { mount( diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx index 6dc5e47392e54..32eaa678fd836 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx @@ -428,6 +428,25 @@ describe('Popover', () => { expect(trigger).toHaveAttribute('aria-expanded', 'true'); }); + it('sets aria-controls on trigger only while open', () => { + const { getByText, getByRole } = render( + + + Trigger + + Surface + , + ); + + const trigger = getByText('Trigger'); + expect(trigger).not.toHaveAttribute('aria-controls'); + + userEvent.click(trigger); + + expect(trigger).toHaveAttribute('aria-controls', 'my-id'); + expect(getByRole('group', { hidden: true })).toHaveAttribute('id', 'my-id'); + }); + it('sets data-open on trigger when open', () => { const { getByText } = render( diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts index 25631e2dc882e..b2e0c541b173e 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts @@ -77,7 +77,7 @@ export type PopoverProps = { /** * Optional id for the popover surface. When provided, this id is applied to * the `PopoverSurface` element and referenced by `PopoverTrigger` - * (`aria-details`). If omitted, a generated id is used. + * (`aria-details` and `aria-controls`). If omitted, a generated id is used. */ id?: string; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/PopoverTrigger.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/PopoverTrigger.types.ts index 4810b962c64fb..e90dd3c824338 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/PopoverTrigger.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/PopoverTrigger.types.ts @@ -6,6 +6,7 @@ import type { PopoverTriggerChildProps as CanonicalPopoverTriggerChildProps } fr type HeadlessPopoverTriggerChildExtras = { 'aria-haspopup'?: 'true'; 'aria-details'?: string; + 'aria-controls'?: string; 'data-open'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/usePopoverTrigger.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/usePopoverTrigger.ts index c9c517bcc4455..403fd96a05001 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/usePopoverTrigger.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/usePopoverTrigger.ts @@ -78,6 +78,7 @@ export const usePopoverTrigger = (props: PopoverTriggerProps): PopoverTriggerSta 'aria-expanded': `${open}` as 'true' | 'false', 'aria-haspopup': 'true' as const, 'aria-details': open ? surfaceId : undefined, + 'aria-controls': open ? surfaceId : undefined, 'data-open': toDataAttributeValue(open), ...child?.props, onMouseEnter: useEventCallback(mergeCallbacks(child?.props.onMouseEnter, onMouseEnter)), diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverBestPractices.md b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverBestPractices.md index 799e0ee6976cb..9c2fbd2cabab8 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverBestPractices.md +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverBestPractices.md @@ -3,7 +3,7 @@ ### Do - Create nested `Popover`s as separate components. -- If there are no interactive items in the `Popover` content, set `tabIndex={-1}` on the `PopoverSurface`. +- If there are no interactive items in the `Popover` content, the browser focuses the `PopoverSurface` itself on open. Give it an accessible name (`aria-label` or `aria-labelledby`) and point `aria-describedby` at the content, so screen readers announce the popover and its text. - Use `Popover` to reduce screen clutter and host non-essential information. ### Don't diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDefault.stories.tsx index 919836daf1509..ba2cfe1ec9ffc 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverDefault.stories.tsx @@ -1,18 +1,27 @@ import * as React from 'react'; +import { useId } from '@fluentui/react-components'; import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; import styles from './popover.module.css'; -export const Default = (): React.ReactNode => ( - - - Show popover - - - Popover title - - This is the content of the popover. Click the trigger again or press Escape to close. - - - -); +export const Default = (): React.ReactNode => { + const headingId = useId('popover-heading-'); + const bodyId = useId('popover-body-'); + + return ( + + + Show popover + + {/* Text-only content: the surface itself receives focus on open, so it needs a name and description. */} + + + Popover title + + + This is the content of the popover. Click the trigger again or press Escape to close. + + + + ); +};
- This is the content of the popover. Click the trigger again or press Escape to close. -
+ This is the content of the popover. Click the trigger again or press Escape to close. +