diff --git a/change/@fluentui-react-headless-components-preview-8f342829-73a2-4dc2-951d-9c89fcfe5321.json b/change/@fluentui-react-headless-components-preview-8f342829-73a2-4dc2-951d-9c89fcfe5321.json new file mode 100644 index 0000000000000..d979703fd67f2 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-8f342829-73a2-4dc2-951d-9c89fcfe5321.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: add native focus navigation and pause timeouts while focus is in a toast stack", + "packageName": "@fluentui/react-headless-components-preview", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.cy.tsx index 87ee5d55f1edb..d5301eec8766d 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.cy.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { mount as mountBase } from '@fluentui/scripts-cypress'; +import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill'; import type { JSXElement } from '@fluentui/react-utilities'; import { Toaster, Toast, ToastTitle, useToastController } from '.'; @@ -13,6 +14,8 @@ import { Provider } from '../Provider'; const TOAST_CONTAINER = '[role="listitem"]'; const TOAST = '[data-intent]'; +polyfillBodyAndObserve(); + const mount = (element: JSXElement) => mountBase( @@ -207,6 +210,149 @@ describe('Toast (headless)', () => { cy.get('#make').click().get(TOAST).trigger('mouseenter').wait(700).get(TOAST).should('exist'); }); + it('should pause all toasts while focus is in the toaster', () => { + const Example = () => { + const { dispatchToast } = useToastController(); + const makeToast = () => { + dispatchToast( + + This is a toast + , + { timeout: 500 }, + ); + dispatchToast( + + This is another toast + , + { timeout: 500 }, + ); + }; + + return ( + <> + + + + ); + }; + + mount(); + cy.get('#make').click().get(TOAST_CONTAINER).first().focus().wait(700); + cy.get(TOAST_CONTAINER).should('have.length', 2); + cy.get('#make').focus().wait(700); + cy.get(TOAST_CONTAINER).should('not.exist'); + }); + + it('should keep toasts dispatched while focus is in the toaster paused', () => { + const Example = () => { + const { dispatchToast } = useToastController(); + const dispatchSecondToast = () => + dispatchToast( + + This is another toast + , + { timeout: 500 }, + ); + const dispatchFirstToast = () => + dispatchToast( + + This is a toast + + , + { timeout: 500 }, + ); + + return ( + <> + + + + ); + }; + + mount(); + cy.get('#make').click(); + cy.get(TOAST_CONTAINER).focus(); + cy.get('#dispatch-second').click().wait(700); + cy.get(TOAST_CONTAINER).should('have.length', 2); + cy.get('#make').focus().wait(700); + cy.get(TOAST_CONTAINER).should('not.exist'); + }); + + it('should keep a toast paused on hover after focus leaves the toaster', () => { + const Example = () => { + const { dispatchToast } = useToastController(); + const makeToast = () => + dispatchToast( + + This is a toast + , + { timeout: 1000, pauseOnHover: true }, + ); + + return ( + <> + + + + ); + }; + + mount(); + cy.get('#make').click(); + cy.get(TOAST_CONTAINER) + .realHover() + .then(toast => toast[0].focus()); + cy.get('#make') + .then(button => button[0].focus()) + .wait(1200); + cy.get(TOAST_CONTAINER).should('exist'); + cy.get('#make').realHover().wait(1200); + cy.get(TOAST_CONTAINER).should('not.exist'); + }); + + it('should move focus between toasts with ArrowDown', () => { + const Example = () => { + const { dispatchToast } = useToastController(); + const makeToast = () => { + dispatchToast( + + First toast + , + { timeout: -1 }, + ); + dispatchToast( + + Second toast + , + { timeout: -1 }, + ); + }; + + return ( + <> + + + + ); + }; + + mount(); + cy.get('#make').click(); + cy.get(TOAST_CONTAINER).first().focus().realPress('ArrowDown'); + cy.get(TOAST_CONTAINER).eq(1).should('be.focused'); + }); + it('should follow lifecycle', () => { const Example = () => { const { dispatchToast } = useToastController(); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.test.tsx index da620c438fca1..43b29b6287a3a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toast.test.tsx @@ -55,6 +55,7 @@ describe('Toast', () => { expect(toast).toHaveTextContent('Default Toast'); expect(toast).toHaveAttribute('data-intent', 'info'); + expect(toast).toHaveAttribute('focusgroup', 'none'); }); it('renders children with error intent', () => { diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainer.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainer.ts index e058b6cdabb33..f4296bf427d2b 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainer.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/ToastContainer/useToastContainer.ts @@ -1,7 +1,15 @@ 'use client'; import * as React from 'react'; -import { getIntrinsicElementProps, slot, useEventCallback, useId, useMergedRefs } from '@fluentui/react-utilities'; +import { + getIntrinsicElementProps, + isHTMLElement, + slot, + useEventCallback, + useId, + useIsomorphicLayoutEffect, + useMergedRefs, +} from '@fluentui/react-utilities'; import { useFluent_unstable } from '@fluentui/react-shared-contexts'; import { Delete } from '@fluentui/keyboard-keys'; import type { ToastPoliteness, ToastStatus } from '@fluentui/react-toast'; @@ -44,7 +52,10 @@ export const useToastContainer = (props: ToastContainerProps, ref: React.Ref(null); const { targetDocument } = useFluent_unstable(); const [running, setRunning] = React.useState(false); + const [isFocusWithinStack, setIsFocusWithinStack] = React.useState(false); const imperativePauseRef = React.useRef(false); + const hoverPauseRef = React.useRef(false); + const windowBlurPauseRef = React.useRef(false); const focusedToastBeforeClose = React.useRef(false); const close = useEventCallback(() => { @@ -59,7 +70,7 @@ export const useToastContainer = (props: ToastContainerProps, ref: React.Ref onStatusChange?.(null, { status, ...props })); const pause = useEventCallback(() => setRunning(false)); const play = useEventCallback(() => { - if (imperativePauseRef.current) { + if (imperativePauseRef.current || hoverPauseRef.current || windowBlurPauseRef.current) { return; } @@ -69,7 +80,7 @@ export const useToastContainer = (props: ToastContainerProps, ref: React.Ref { + windowBlurPauseRef.current = false; + play(); + }); + const onWindowBlur = useEventCallback(() => { + windowBlurPauseRef.current = true; + pause(); + }); + React.useEffect(() => { return () => reportStatus('unmounted'); }, [reportStatus]); @@ -98,13 +118,50 @@ export const useToastContainer = (props: ToastContainerProps, ref: React.Ref { + targetDocument.defaultView?.removeEventListener('focus', onWindowFocus); + targetDocument.defaultView?.removeEventListener('blur', onWindowBlur); + }; + }, [targetDocument, onWindowBlur, onWindowFocus, pauseOnWindowBlur]); + + React.useEffect(() => { + if (isFocusWithinStack) { + pause(); + } else { + play(); + } + }, [isFocusWithinStack, pause, play]); + + useIsomorphicLayoutEffect(() => { + const stack = toastRef.current?.parentElement; + if (!stack) { + return; + } + + if (isHTMLElement(targetDocument?.activeElement) && stack.contains(targetDocument.activeElement)) { + setIsFocusWithinStack(true); + pause(); + } + + const onFocusIn = () => { + setIsFocusWithinStack(true); + pause(); + }; + const onFocusOut = (e: FocusEvent) => { + if (!stack.contains(isHTMLElement(e.relatedTarget) ? e.relatedTarget : null)) { + setIsFocusWithinStack(false); + } + }; + + stack.addEventListener('focusin', onFocusIn); + stack.addEventListener('focusout', onFocusOut); return () => { - targetDocument.defaultView?.removeEventListener('focus', play); - targetDocument.defaultView?.removeEventListener('blur', pause); + stack.removeEventListener('focusin', onFocusIn); + stack.removeEventListener('focusout', onFocusOut); }; - }, [targetDocument, pause, play, pauseOnWindowBlur]); + }, [pause, targetDocument]); React.useEffect(() => { if (!visible) { @@ -145,6 +202,7 @@ export const useToastContainer = (props: ToastContainerProps, ref: React.Ref) => { if (pauseOnHover) { + hoverPauseRef.current = true; pause(); } userRootSlot?.onMouseEnter?.(e); @@ -152,6 +210,7 @@ export const useToastContainer = (props: ToastContainerProps, ref: React.Ref) => { if (pauseOnHover) { + hoverPauseRef.current = false; play(); } userRootSlot?.onMouseLeave?.(e); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.test.tsx index 326bea3ddaa98..c5d6c61854ef3 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/Toaster.test.tsx @@ -45,6 +45,21 @@ describe('Toaster', () => { expect(document.body.querySelectorAll('[aria-live]').length).toBe(before); }); + it('uses list semantics with native block-axis focusgroup navigation', () => { + let dispatchToast: ReturnType['dispatchToast']; + const Test = () => { + dispatchToast = useToastController().dispatchToast; + return ; + }; + const { getByRole } = render(); + + act(() => { + dispatchToast('toast', { timeout: -1 }); + }); + + expect(getByRole('list')).toHaveAttribute('focusgroup', 'toolbar block itemcontrols'); + }); + it('limits the number of rendered toasts', () => { let dispatchToast: ReturnType['dispatchToast']; const toasterId = 'limited-toaster'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx index 4f567364eb436..326fdab5d92a1 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/Toaster/useToaster.tsx @@ -37,16 +37,36 @@ export const useToaster = (props: ToasterProps): ToasterState => { ...rest } = props; - const { toastsToRender, isToastVisible, tryRestoreFocus, closeAllToasts } = useToasterState({ - toasterId, - position, - timeout, - pauseOnWindowBlur, - pauseOnHover, - priority, - shortcuts, - limit, - }); + const playAllToastsRef = React.useRef<() => void>(() => undefined); + const toasterShortcuts = React.useMemo( + () => + shortcuts + ? { + focus: (e: KeyboardEvent) => { + const isFocusShortcut = shortcuts.focus(e); + if (isFocusShortcut) { + Promise.resolve().then(() => playAllToastsRef.current()); + } + return isFocusShortcut; + }, + } + : undefined, + [shortcuts], + ); + const { toastsToRender, isToastVisible, playAllToasts, tryRestoreFocus, closeAllToasts } = + useToasterState({ + toasterId, + position, + timeout, + pauseOnWindowBlur, + pauseOnHover, + priority, + shortcuts: toasterShortcuts, + limit, + }); + useIsomorphicLayoutEffect(() => { + playAllToastsRef.current = playAllToasts; + }, [playAllToasts]); const announceRef = React.useRef(() => null); const announce = React.useCallback((message, options) => announceRef.current(message, options), []); @@ -104,6 +124,7 @@ export const useToaster = (props: ToasterProps): ToasterState => { {toast.content as React.ReactNode} )), + focusgroup: 'toolbar block itemcontrols', onKeyDown, popover: 'manual' as const, 'data-toaster-position': toastPosition, diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToast.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToast.ts index c87e552232286..a75da3d17c699 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToast.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toast/useToast.ts @@ -13,6 +13,8 @@ export const useToast = (props: ToastProps, ref: React.Ref): ToastS // eslint-disable-next-line react-hooks/immutability state.root['data-intent'] = state.intent; + // eslint-disable-next-line react-hooks/immutability + state.root.focusgroup = 'none'; return state; };