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 @@
{
Comment thread
PaulGMardling marked this conversation as resolved.
"type": "patch",
"comment": "fix: move focus to the active page's TeachingPopoverTitle after Next/Previous navigation while preserving navigation-tab focus and newer focus choices",
"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
@@ -1,15 +1,27 @@
'use client';

import * as React from 'react';
import { isHTMLElement, useMergedRefs, useControllableState, useEventCallback } from '@fluentui/react-utilities';
import {
isHTMLElement,
useMergedRefs,
useControllableState,
useEventCallback,
useIsomorphicLayoutEffect,
} 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';
import type { CarouselContextValue } from './CarouselContext';

type CarouselFocusRequest = {
requestedValue: string;
origin: Element | null;
committed: boolean;
};

// TODO: Migrate this into an external @fluentui/carousel component
// For now, we won't export this publicly, is only for internal TeachingPopover use until stabilized.
export function useCarousel_unstable(options: UseCarouselOptions): {
Expand Down Expand Up @@ -40,6 +52,26 @@ export function useCarousel_unstable(options: UseCarouselOptions): {

const { announce } = useAnnounce();

const previousValueRef = React.useRef(value);
const focusRequestRef = React.useRef<CarouselFocusRequest | null>(null);

// A controlled value does not carry the request that caused it, so delayed acceptance is recognized by matching
// the latest directional request while its captured focus origin still owns focus. Any different committed value,
// direct tab activation, or newer directional request supersedes it.
useIsomorphicLayoutEffect(() => {
if (previousValueRef.current === value) {
return;
}

previousValueRef.current = value;

if (focusRequestRef.current?.requestedValue === value) {
focusRequestRef.current.committed = true;
} else {
focusRequestRef.current = null;
}
});

if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
Expand Down Expand Up @@ -80,7 +112,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 +126,29 @@ export function useCarousel_unstable(options: UseCarouselOptions): {
const previousNode = carouselWalker.prevPage(newNode?.value);
store.insertValue(newValue, previousNode?.value ?? null);
}

const focusRequest = focusRequestRef.current;

if (focusRequest?.committed) {
const titleEl = addedNode.matches(`[${CAROUSEL_TITLE}]`)
? addedNode
: addedNode.querySelector<HTMLElement>(`[${CAROUSEL_TITLE}]`);

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

if (titleEl && owningItemValue === focusRequest.requestedValue) {
const activeElement: Element | null = targetDocument?.activeElement ?? null;
const originWasRemoved = focusRequest.origin !== null && !focusRequest.origin.isConnected;
const requestStillOwnsFocus =
activeElement === focusRequest.origin || (originWasRemoved && activeElement === targetDocument?.body);

if (requestStillOwnsFocus) {
titleEl.focus({ preventScroll: true });
}

focusRequestRef.current = null;
}
}
}

for (const removedNode of Array.from(mutation.removedNodes)) {
Expand All @@ -112,7 +171,7 @@ export function useCarousel_unstable(options: UseCarouselOptions): {
return () => {
observer.disconnect();
};
}, [carouselWalker, store, win]);
}, [carouselWalker, store, targetDocument, win]);

const updateSlide = useEventCallback(
(event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>, newValue: string) => {
Expand All @@ -137,19 +196,29 @@ export function useCarousel_unstable(options: UseCarouselOptions): {
direction === 'prev' ? carouselWalker.prevPage(active.value) : carouselWalker.nextPage(active.value);

if (newPage) {
focusRequestRef.current = {
requestedValue: newPage.value,
origin: targetDocument?.activeElement ?? null,
committed: false,
};
updateSlide(event, newPage?.value);
} else {
onFinish?.(event, { event, type: 'click', value: active?.value });
}
});

const selectPageByValue: CarouselContextValue['selectPageByValue'] = useEventCallback((event, newValue) => {
focusRequestRef.current = null;
updateSlide(event, newValue);
});

return {
carouselRef: useMergedRefs(rootRef, carouselRef),
carousel: {
store,
value,
selectPageByDirection,
selectPageByValue: updateSlide,
selectPageByValue,
},
};
}
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';
Loading
Loading