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 @@
{
"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"
}
Original file line number Diff line number Diff line change
@@ -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 '.';
Expand All @@ -13,6 +14,8 @@ import { Provider } from '../Provider';
const TOAST_CONTAINER = '[role="listitem"]';
const TOAST = '[data-intent]';

polyfillBodyAndObserve();

const mount = (element: JSXElement) =>
mountBase(
<Provider>
Expand Down Expand Up @@ -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(
<Toast>
<ToastTitle>This is a toast</ToastTitle>
</Toast>,
{ timeout: 500 },
);
dispatchToast(
<Toast>
<ToastTitle>This is another toast</ToastTitle>
</Toast>,
{ timeout: 500 },
);
};

return (
<>
<button id="make" onClick={makeToast}>
Make toast
</button>
<Toaster />
</>
);
};

mount(<Example />);
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(
<Toast>
<ToastTitle>This is another toast</ToastTitle>
</Toast>,
{ timeout: 500 },
);
const dispatchFirstToast = () =>
dispatchToast(
<Toast>
<ToastTitle>This is a toast</ToastTitle>
<button id="dispatch-second" onClick={dispatchSecondToast}>
Dispatch another toast
</button>
</Toast>,
{ timeout: 500 },
);

return (
<>
<button id="make" onClick={dispatchFirstToast}>
Make toast
</button>
<Toaster />
</>
);
};

mount(<Example />);
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(
<Toast>
<ToastTitle>This is a toast</ToastTitle>
</Toast>,
{ timeout: 1000, pauseOnHover: true },
);

return (
<>
<button id="make" onClick={makeToast}>
Make toast
</button>
<Toaster />
</>
);
};

mount(<Example />);
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(
<Toast>
<ToastTitle>First toast</ToastTitle>
</Toast>,
{ timeout: -1 },
);
dispatchToast(
<Toast>
<ToastTitle>Second toast</ToastTitle>
</Toast>,
{ timeout: -1 },
);
};

return (
<>
<button id="make" onClick={makeToast}>
Make toast
</button>
<Toaster />
</>
);
};

mount(<Example />);
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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -44,7 +52,10 @@ export const useToastContainer = (props: ToastContainerProps, ref: React.Ref<HTM
const toastRef = React.useRef<HTMLDivElement | null>(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(() => {
Expand All @@ -59,7 +70,7 @@ export const useToastContainer = (props: ToastContainerProps, ref: React.Ref<HTM
const reportStatus = useEventCallback((status: ToastStatus) => onStatusChange?.(null, { status, ...props }));
const pause = useEventCallback(() => setRunning(false));
const play = useEventCallback(() => {
if (imperativePauseRef.current) {
if (imperativePauseRef.current || hoverPauseRef.current || windowBlurPauseRef.current) {
return;
}

Expand All @@ -69,7 +80,7 @@ export const useToastContainer = (props: ToastContainerProps, ref: React.Ref<HTM
}

const activeElement = targetDocument?.activeElement;
const containsActive = !!(activeElement && toastRef.current?.contains(activeElement));
const containsActive = !!(activeElement && toastRef.current?.parentElement?.contains(activeElement));
if (!containsActive) {
setRunning(true);
Comment on lines 82 to 85
}
Expand All @@ -89,6 +100,15 @@ export const useToastContainer = (props: ToastContainerProps, ref: React.Ref<HTM
},
}));

const onWindowFocus = useEventCallback(() => {
windowBlurPauseRef.current = false;
play();
});
const onWindowBlur = useEventCallback(() => {
windowBlurPauseRef.current = true;
pause();
});

React.useEffect(() => {
return () => reportStatus('unmounted');
}, [reportStatus]);
Expand All @@ -98,13 +118,50 @@ export const useToastContainer = (props: ToastContainerProps, ref: React.Ref<HTM
return;
}

targetDocument.defaultView?.addEventListener('focus', play);
targetDocument.defaultView?.addEventListener('blur', pause);
targetDocument.defaultView?.addEventListener('focus', onWindowFocus);
targetDocument.defaultView?.addEventListener('blur', onWindowBlur);
return () => {
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) {
Expand Down Expand Up @@ -145,13 +202,15 @@ export const useToastContainer = (props: ToastContainerProps, ref: React.Ref<HTM

const onMouseEnter = useEventCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (pauseOnHover) {
hoverPauseRef.current = true;
pause();
}
userRootSlot?.onMouseEnter?.(e);
});

const onMouseLeave = useEventCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (pauseOnHover) {
hoverPauseRef.current = false;
play();
}
userRootSlot?.onMouseLeave?.(e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useToastController>['dispatchToast'];
const Test = () => {
dispatchToast = useToastController().dispatchToast;
return <Toaster />;
};
const { getByRole } = render(<Test />);

act(() => {
dispatchToast('toast', { timeout: -1 });
});

expect(getByRole('list')).toHaveAttribute('focusgroup', 'toolbar block itemcontrols');
});

it('limits the number of rendered toasts', () => {
let dispatchToast: ReturnType<typeof useToastController>['dispatchToast'];
const toasterId = 'limited-toaster';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,36 @@ export const useToaster = (props: ToasterProps): ToasterState => {
...rest
} = props;

const { toastsToRender, isToastVisible, tryRestoreFocus, closeAllToasts } = useToasterState<HTMLDivElement>({
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<HTMLDivElement>({
toasterId,
position,
timeout,
pauseOnWindowBlur,
pauseOnHover,
priority,
shortcuts: toasterShortcuts,
limit,
});
useIsomorphicLayoutEffect(() => {
playAllToastsRef.current = playAllToasts;
}, [playAllToasts]);

const announceRef = React.useRef<ToastAnnounce>(() => null);
const announce = React.useCallback<ToastAnnounce>((message, options) => announceRef.current(message, options), []);
Expand Down Expand Up @@ -104,6 +124,7 @@ export const useToaster = (props: ToasterProps): ToasterState => {
{toast.content as React.ReactNode}
</ToastContainer>
)),
focusgroup: 'toolbar block itemcontrols',
Comment thread
PaulGMardling marked this conversation as resolved.
Comment thread
dmytrokirpa marked this conversation as resolved.
onKeyDown,
popover: 'manual' as const,
'data-toaster-position': toastPosition,
Expand Down
Loading
Loading