Skip to content
Draft
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 @@
{
Comment thread
PaulGMardling marked this conversation as resolved.
"type": "patch",
"comment": "fix: move focus to the active page's TeachingPopoverTitle when a TeachingPopoverCarousel step changes, so assistive technology announces the new heading and step count in a single pass (fixes screen reader not announcing step changes on Next/Previous)",
"packageName": "@fluentui/react-teaching-popover",
"email": "paulmardling@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
/** Jest test setup file. */

require('@testing-library/jest-dom');
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as React from 'react';
import { isHTMLElement, useMergedRefs, useControllableState, useEventCallback } from '@fluentui/react-utilities';
import { useAnnounce, useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';

import { CAROUSEL_ITEM } from './constants';
import { CAROUSEL_ITEM, CAROUSEL_TITLE } from './constants';
import { useCarouselWalker_unstable } from './useCarouselWalker';
import { createCarouselStore } from './createCarouselStore';
import type { CarouselStore, UseCarouselOptions } from './Carousel.types';
Expand Down Expand Up @@ -40,6 +40,21 @@ export function useCarousel_unstable(options: UseCarouselOptions): {

const { announce } = useAnnounce();

// Tracks the value of a carousel page that is in the process of becoming active, so that focus is only moved
// to a page's title when its DOM node mounts *because of* a navigation - not whenever any
// `[data-carousel-title]` node happens to be added anywhere under the carousel (e.g. unrelated async content).
const pendingFocusValueRef = React.useRef<string | null>(null);
const isInitialRenderRef = React.useRef(true);

React.useEffect(() => {
if (isInitialRenderRef.current) {
isInitialRenderRef.current = false;
return;
}

pendingFocusValueRef.current = value;
}, [value]);

if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
Expand Down Expand Up @@ -80,7 +95,11 @@ export function useCarousel_unstable(options: UseCarouselOptions): {
const callback: MutationCallback = mutationList => {
for (const mutation of mutationList) {
for (const addedNode of Array.from(mutation.addedNodes)) {
if (isHTMLElement(addedNode) && addedNode.hasAttribute(CAROUSEL_ITEM)) {
if (!isHTMLElement(addedNode)) {
continue;
}

if (addedNode.hasAttribute(CAROUSEL_ITEM)) {
const newValue = addedNode.getAttribute(CAROUSEL_ITEM)!;
const newNode = carouselWalker.find(newValue);
if (!newNode?.value) {
Expand All @@ -90,6 +109,26 @@ export function useCarousel_unstable(options: UseCarouselOptions): {
const previousNode = carouselWalker.prevPage(newNode?.value);
store.insertValue(newValue, previousNode?.value ?? null);
}

// Move focus to a page's title only when it mounts as part of an actual navigation to it (tracked via
// `pendingFocusValueRef`), so assistive technology announces the updated heading (and any
// aria-describedby'd step count) in a single pass. A page's own root element (marked with
// `data-carousel-item`) is never removed/re-added on navigation - only its children toggle - so the
// title's *owning* item is resolved via the closest `[data-carousel-item]` ancestor and compared
// against the pending value. This ensures unrelated title mounts elsewhere in the carousel - e.g. async
// content added to a page that isn't the one just navigated to - never steal focus.
if (pendingFocusValueRef.current !== null) {
const titleEl = addedNode.matches(`[${CAROUSEL_TITLE}]`)
? addedNode
: addedNode.querySelector<HTMLElement>(`[${CAROUSEL_TITLE}]`);

const owningItemValue = titleEl?.closest(`[${CAROUSEL_ITEM}]`)?.getAttribute(CAROUSEL_ITEM);

if (titleEl && owningItemValue === pendingFocusValueRef.current) {
titleEl.focus({ preventScroll: true });
pendingFocusValueRef.current = null;
}
}
}

for (const removedNode of Array.from(mutation.removedNodes)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
export const CAROUSEL_ITEM = 'data-carousel-item';
export const CAROUSEL_ACTIVE_ITEM = 'data-carousel-active-item';

/**
* Marks the heading (TeachingPopoverTitle) belonging to a carousel page, so that focus can be moved to it
* when the active page changes. This lets assistive technology announce the new step's accessible name/role
* in a single pass, instead of relying solely on a separate live region announcement.
*/
export const CAROUSEL_TITLE = 'data-carousel-title';
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { isConformant } from '../../testing/isConformant';
import { TeachingPopoverCarousel } from './TeachingPopoverCarousel';
import { TeachingPopoverCarouselCard } from '../TeachingPopoverCarouselCard/TeachingPopoverCarouselCard';
import { TeachingPopoverCarouselFooter } from '../TeachingPopoverCarouselFooter/TeachingPopoverCarouselFooter';
import { TeachingPopoverTitle } from '../TeachingPopoverTitle/TeachingPopoverTitle';

describe('TeachingPopoverCarousel', () => {
isConformant({
Expand All @@ -21,4 +24,90 @@ describe('TeachingPopoverCarousel', () => {
);
expect(result.container).toMatchSnapshot();
});

it('moves focus to the new page title when navigating to the next page', async () => {
render(
<TeachingPopoverCarousel defaultValue="one">
<TeachingPopoverCarouselCard value="one">
<TeachingPopoverTitle>Step one</TeachingPopoverTitle>
</TeachingPopoverCarouselCard>
<TeachingPopoverCarouselCard value="two">
<TeachingPopoverTitle>Step two</TeachingPopoverTitle>
</TeachingPopoverCarouselCard>
<TeachingPopoverCarouselFooter next="Next" previous="Previous" initialStepText="Close" finalStepText="Finish">
Footer
</TeachingPopoverCarouselFooter>
</TeachingPopoverCarousel>,
);

fireEvent.click(screen.getByRole('button', { name: 'Next' }));

await waitFor(() => expect(screen.getByText('Step two')).toHaveFocus());
});

it('moves focus to the new page title when navigating to the previous page', async () => {
render(
<TeachingPopoverCarousel defaultValue="two">
<TeachingPopoverCarouselCard value="one">
<TeachingPopoverTitle>Step one</TeachingPopoverTitle>
</TeachingPopoverCarouselCard>
<TeachingPopoverCarouselCard value="two">
<TeachingPopoverTitle>Step two</TeachingPopoverTitle>
</TeachingPopoverCarouselCard>
<TeachingPopoverCarouselFooter next="Next" previous="Previous" initialStepText="Close" finalStepText="Finish">
Footer
</TeachingPopoverCarouselFooter>
</TeachingPopoverCarousel>,
);

fireEvent.click(screen.getByRole('button', { name: 'Previous' }));

await waitFor(() => expect(screen.getByText('Step one')).toHaveFocus());
});

it('does not move focus to the title on initial render', async () => {
render(
<TeachingPopoverCarousel defaultValue="one">
<TeachingPopoverCarouselCard value="one">
<TeachingPopoverTitle>Step one</TeachingPopoverTitle>
</TeachingPopoverCarouselCard>
</TeachingPopoverCarousel>,
);

// Flush any pending microtasks (e.g. the carousel's mutation observer) before asserting.
await Promise.resolve();

expect(screen.getByText('Step one')).not.toHaveFocus();
});

it('does not move focus when a title mounts on the active page outside of a navigation', async () => {
const AsyncTitle = () => {
const [loaded, setLoaded] = React.useState(false);

React.useEffect(() => {
setLoaded(true);
}, []);

return loaded ? <TeachingPopoverTitle>Async step one</TeachingPopoverTitle> : null;
};

render(
<TeachingPopoverCarousel defaultValue="one">
<TeachingPopoverCarouselCard value="one">
<button type="button">Focus me</button>
<AsyncTitle />
</TeachingPopoverCarouselCard>
</TeachingPopoverCarousel>,
);

const button = screen.getByRole('button', { name: 'Focus me' });
button.focus();

// Wait for the async title to mount, plus any pending microtasks (e.g. the carousel's mutation observer).
await waitFor(() => expect(screen.getByText('Async step one')).toBeInTheDocument());
await Promise.resolve();

expect(button).toHaveFocus();
expect(screen.getByText('Async step one')).not.toHaveFocus();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ exports[`TeachingPopoverTitle renders a default state 1`] = `
<div>
<h2
class="fui-TeachingPopoverTitle"
data-carousel-title="true"
tabindex="-1"
>
Default TeachingPopoverTitle
</h2>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type * as React from 'react';
import { usePopoverContext_unstable } from '@fluentui/react-popover';
import { getIntrinsicElementProps, slot, useEventCallback } from '@fluentui/react-utilities';
import { CAROUSEL_TITLE } from '../TeachingPopoverCarousel/Carousel/constants';
import type { TeachingPopoverTitleBaseProps, TeachingPopoverTitleBaseState } from './TeachingPopoverTitle.types';

/**
Expand Down Expand Up @@ -38,6 +39,10 @@ export const useTeachingPopoverTitleBase_unstable = (
root: slot.always(
getIntrinsicElementProps('h2', {
ref,
// Not in the tab sequence, but programmatically focusable so a TeachingPopoverCarousel can move focus
// here when the active page changes, letting assistive technology announce the new title in one pass.
tabIndex: -1,
[CAROUSEL_TITLE]: true,
...props,
}),
{ elementType: 'h2' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"compilerOptions": {
"module": "CommonJS",
"outDir": "dist",
"types": ["jest", "node"]
"types": ["jest", "node", "@testing-library/jest-dom"]
},
"include": [
"**/*.spec.ts",
Expand Down
Loading