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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{

@github-actions github-actions Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Menu 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Menu.Nested Submenus Small Viewport Stacked.nested menu.chromium.png 856 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 618 Changed
vr-tests-react-components/Positioning.Positioning end.chromium.png 728 Changed
vr-tests-react-components/ProgressBar converged 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 51 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png 105 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png 114 Changed
vr-tests-react-components/TagPicker 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - Dark Mode.chromium.png 658 Changed
vr-tests-react-components/TagPicker.disabled.disabled input hover.chromium.png 677 Changed

There were 2 duplicate changes discarded. Check the build logs for more information.

"type": "patch",
"comment": "omit role=group from PopoverSurface when no accessible name is provided, fixing Narrator announcing contains style group on non-modal popovers",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
"comment": "omit role=group from PopoverSurface when no accessible name is provided, fixing Narrator announcing contains style group on non-modal popovers",
"comment": "fix: omit group role from PopoverSurface when no accessible name is provided",

"packageName": "@fluentui/react-popover",
"email": "paulmardling@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,6 @@ Object {
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div
Expand Down Expand Up @@ -640,7 +639,6 @@ Object {
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div
Expand Down Expand Up @@ -900,7 +898,6 @@ Object {
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div
Expand Down Expand Up @@ -1120,7 +1117,6 @@ Object {
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,6 @@ exports[`GanttChart interaction and accessibility tests should render custom cal
data-popper-placement="top"
data-popper-reference-hidden=""
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: absolute; left: 0px; top: 0px; margin: 0px; box-sizing: border-box; max-width: 0px; max-height: -20px; overflow-y: auto; transform: translate(-10px, -40px); --fui-positioning-slide-direction-x: 0px; --fui-positioning-slide-direction-y: 1px;"
>
<div
Expand Down Expand Up @@ -4762,7 +4761,6 @@ exports[`GanttChart rendering and behavior tests should render callout correctly
data-popper-placement="top"
data-popper-reference-hidden=""
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: absolute; left: 0px; top: 0px; margin: 0px; box-sizing: border-box; max-width: 0px; max-height: -20px; overflow-y: auto; transform: translate(-10px, -40px); --fui-positioning-slide-direction-x: 0px; --fui-positioning-slide-direction-y: 1px;"
>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,6 @@ exports[`Gauge chart interactions Should hide callout on mouse leave 1`] = `
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div
Expand Down Expand Up @@ -1559,7 +1558,6 @@ exports[`Gauge chart interactions Should show callout on focus 1`] = `
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div
Expand Down Expand Up @@ -1859,7 +1857,6 @@ exports[`Gauge chart interactions Should show callout on mouse over 1`] = `
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1309,7 +1309,6 @@ exports[`GroupedVerticalBarChart - mouse events Should render callout correctly
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1878,7 +1878,6 @@ exports[`LineChart - mouse events Should render callout correctly on mouseover 1
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div
Expand Down Expand Up @@ -2553,7 +2552,6 @@ exports[`LineChart - mouse events Should render customized callout per stack on
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11596,7 +11596,6 @@ exports[`SankeyChart - mouse events Should render callout correctly on mouseover
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4983,7 +4983,6 @@ exports[`VerticalBarChart - mouse events Should render callout correctly on mous
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div
Expand Down Expand Up @@ -5609,7 +5608,6 @@ exports[`VerticalBarChart - mouse events Should render customized callout on mou
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2152,7 +2152,6 @@ exports[`VerticalStackedBarChart - mouse events Should render callout correctly
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div
Expand Down Expand Up @@ -2724,7 +2723,6 @@ exports[`VerticalStackedBarChart - mouse events Should render customized callout
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div>
Expand Down Expand Up @@ -3338,7 +3336,6 @@ exports[`VerticalStackedBarChart - mouse events Should render customized callout
<div
class="fui-PopoverSurface"
data-tabster="{\\"restorer\\":{\\"type\\":0}}"
role="group"
style="position: fixed; left: 0px; top: 0px; margin: 0px;"
>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const mount = (element: JSXElement) => {
};

const popoverTriggerSelector = '[aria-expanded]';
const popoverContentSelector = '[role="group"]';
const popoverContentSelector = '.fui-PopoverSurface';
const popoverInteractiveContentSelector = '[role="dialog"]';

describe('Popover', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ import { usePopover_unstable } from './usePopover';
import { isConformant } from '../../testing/isConformant';
import type { PopoverProps } from './Popover.types';

// useFocusFinders requires tabster to be initialized on the document (via FluentProvider),
// which does not happen in unit tests. Mock it with a real DOM-based implementation
// so findFirstFocusable works correctly in jsdom.
jest.mock('@fluentui/react-tabster', () => {
const FOCUSABLE_SELECTOR =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
return {
...jest.requireActual('@fluentui/react-tabster'),
useFocusFinders: () => ({
findFirstFocusable: (container: HTMLElement | null) =>
container?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? undefined,
}),
};
});

describe('Popover', () => {
isConformant({
Component: Popover,
Expand Down Expand Up @@ -216,4 +231,99 @@ describe('Popover', () => {
document.body.removeChild(popoverContent);
});
});

describe('dev warning for non-focusable content', () => {
let warnSpy: jest.SpyInstance;

beforeEach(() => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});

afterEach(() => {
warnSpy.mockRestore();
});

it('warns when popover opens without focusable content', () => {
const content = document.createElement('div');
content.textContent = 'Static content';

const { result, rerender } = renderHook(
({ open }) =>
usePopover_unstable({
open,
children: <div />,
}),
{ initialProps: { open: false } },
);

act(() => {
(result.current.contentRef as React.RefObject<HTMLElement | null>).current = content;
});

rerender({ open: true });

expect(warnSpy).toHaveBeenCalledWith(
'Non-modal Popover with non-interactive content may not be announced by screen readers because focus remains on the trigger. Consider using Tooltip for informational content, or make the Popover content focusable if user interaction is expected.',
);
});

it('does not warn when popover opens with focusable content', () => {
const content = document.createElement('div');
const button = document.createElement('button');
button.textContent = 'Focusable';
content.appendChild(button);
document.body.appendChild(content);

const { result, rerender } = renderHook(
({ open }) =>
usePopover_unstable({
open,
children: <div />,
}),
{ initialProps: { open: false } },
);

act(() => {
(result.current.contentRef as React.RefObject<HTMLElement | null>).current = content;
});

rerender({ open: true });

expect(warnSpy).not.toHaveBeenCalledWith(
'Non-modal Popover with non-interactive content may not be announced by screen readers because focus remains on the trigger. Consider using Tooltip for informational content, or make the Popover content focusable if user interaction is expected.',
);

document.body.removeChild(content);
});

it('warns only once per popover instance', () => {
const content = document.createElement('div');
content.textContent = 'Static content';

const { result, rerender } = renderHook(
({ open }) =>
usePopover_unstable({
open,
children: <div />,
}),
{ initialProps: { open: false } },
);

act(() => {
(result.current.contentRef as React.RefObject<HTMLElement | null>).current = content;
});

rerender({ open: true });
rerender({ open: false });
rerender({ open: true });

const matchingWarnings = warnSpy.mock.calls.filter(
call =>
call[0] ===
'Non-modal Popover with non-interactive content may not be announced by screen readers because focus remains on the trigger. Consider using Tooltip for informational content, or make the Popover content focusable if user interaction is expected.',
);

expect(matchingWarnings).toHaveLength(1);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { PopoverSurfaceMotion } from './PopoverSurfaceMotion';
export const usePopover_unstable = (props: PopoverProps): PopoverState => {
const [contextTarget, setContextTarget] = usePositioningMouseTarget();
const { targetDocument } = useFluent();
const hasWarnedForNonFocusableContent = React.useRef(false);

const positioning = resolvePositioningShorthand(props.positioning);
const handlePositionEnd = usePositioningSlideDirection({
Expand Down Expand Up @@ -182,6 +183,26 @@ export const usePopover_unstable = (props: PopoverProps): PopoverState => {
}
}, [findFirstFocusable, activateModal, open, positioningRefs.contentRef, props.unstable_disableAutoFocus]);

React.useEffect(() => {
if (process.env.NODE_ENV !== 'production') {
if (hasWarnedForNonFocusableContent.current || !open) {
return;
}

const contentElement = positioningRefs.contentRef.current;

if (!contentElement || findFirstFocusable(contentElement)) {
return;
}

// eslint-disable-next-line no-console
console.warn(
'Non-modal Popover with non-interactive content may not be announced by screen readers because focus remains on the trigger. Consider using Tooltip for informational content, or make the Popover content focusable if user interaction is expected.',
);
hasWarnedForNonFocusableContent.current = true;
}
}, [findFirstFocusable, open, positioningRefs.contentRef]);

return {
components: {
surfaceMotion: PopoverSurfaceMotion,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,13 @@ describe('PopoverSurface', () => {
expect(queryByRole('dialog')).not.toBeNull();
});

it('should set role group if focus trap is not active', () => {
it('should set role group if focus trap is not active and an accessible name is provided', () => {
// Arrange
mockPopoverContext({ trapFocus: false });
// props includes aria-label='test', so role='group' should be applied
const { getByTestId } = render(<PopoverSurface {...props}>Content</PopoverSurface>);

// Assert
expect(getByTestId(testid)).not.toBeNull();
expect(getByTestId(testid).getAttribute('role')).toEqual('group');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@ import { usePopoverContext_unstable } from '../../popoverContext';
import type { PopoverSurfaceProps, PopoverSurfaceState } from './PopoverSurface.types';
import { useMotionForwardedRef } from '@fluentui/react-motion';

type PopoverSurfaceAriaAttributes = Pick<React.HTMLAttributes<HTMLDivElement>, 'role' | 'aria-modal'>;

/**
* Returns the ARIA role and modal attributes for a PopoverSurface.
*
* - `trapFocus=true` → `role="dialog"` + `aria-modal=true`
* - `trapFocus=false` + accessible name present → `role="group"`
* - `trapFocus=false` + no accessible name → no role (plain div avoids screen reader
* announcing "contains style group" for unlabelled popovers)
*/
export const getPopoverSurfaceAriaAttributes = ({
Comment thread
PaulGMardling marked this conversation as resolved.
hasAccessibleName,
trapFocus,
}: {
hasAccessibleName: boolean;
trapFocus: boolean;
}): PopoverSurfaceAriaAttributes => {
if (trapFocus) {
return { role: 'dialog', 'aria-modal': true };
}

if (hasAccessibleName) {
return { role: 'group' };
}

return {};
};

/**
* Create the state required to render PopoverSurface.
*
Expand All @@ -28,7 +56,7 @@ export const usePopoverSurface_unstable = (
const size = usePopoverContext_unstable(context => context.size);
const withArrow = usePopoverContext_unstable(context => context.withArrow);
const appearance = usePopoverContext_unstable(context => context.appearance);
const trapFocus = usePopoverContext_unstable(context => context.trapFocus);
const trapFocus = Boolean(usePopoverContext_unstable(context => context.trapFocus));
const inertTrapFocus = usePopoverContext_unstable(context => context.inertTrapFocus);
const inline = usePopoverContext_unstable(context => context.inline);
const motionForwardedRef = useMotionForwardedRef();
Expand All @@ -54,8 +82,10 @@ export const usePopoverSurface_unstable = (
// `contentRef` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement`
// but since it would be a breaking change to fix it, we are casting ref to it's proper type
ref: useMergedRefs(ref, contentRef, motionForwardedRef) as React.Ref<HTMLDivElement>,
role: trapFocus ? 'dialog' : 'group',
'aria-modal': trapFocus ? true : undefined,
...getPopoverSurfaceAriaAttributes({
hasAccessibleName: Boolean(props['aria-label'] || props['aria-labelledby']),
trapFocus,
}),
...modalAttributes,
...props,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const Default = (props: PopoverProps): JSXElement => (
<Button>Popover trigger</Button>
</PopoverTrigger>

<PopoverSurface tabIndex={-1}>
<PopoverSurface tabIndex={-1} aria-label="Popover content">
<ExampleContent />
</PopoverSurface>
</Popover>
Expand Down
Loading