+ How was your experience completing this task?
+ setExperienceFeedbackSent(true)}
+ icon={}
+ />
+ setExperienceFeedbackSent(true)}
+ icon={}
+ />
+
+ >
+ ) : (
+
Thanks for submitting feedback!
+ )}
+ {!deliveryFeedbackSent ? (
+ <>
+
+ Was your message delivered successfully?
+ setDeliveryFeedbackSent(true)} icon={} />
+ setDeliveryFeedbackSent(true)} icon={} />
+
+ >
+ ) : (
+
Thanks for submitting feedback!
+ )}
+
+ );
+};
+
+UserRestoreFocus.parameters = {
+ docs: {
+ description: {
+ story: [
+ 'If the user manually moves focus to a desired element, then the utility **will not move focus**.',
+ 'The focus will only be restored if it is lost to the `document body`.',
+ '',
+ 'This example is similar to the previous. However, submitting the second feedback will manually move',
+ "focus to the 'Send message' button. This bypasses the restore focus history, which should restore",
+ 'focus to the first feedback button.',
+ ].join('\n'),
+ },
+ },
+};
diff --git a/packages/react-components/react-components/stories/Concepts/FocusManagement/useRestoreFocusSource/index.stories.tsx b/packages/react-components/react-components/stories/Concepts/FocusManagement/useRestoreFocusSource/index.stories.tsx
new file mode 100644
index 0000000000000..df89cfc6c8c6e
--- /dev/null
+++ b/packages/react-components/react-components/stories/Concepts/FocusManagement/useRestoreFocusSource/index.stories.tsx
@@ -0,0 +1,18 @@
+import { useRestoreFocusSource } from '@fluentui/react-components';
+import descriptionMd from './useRestoreFocusSourceDescription.md';
+
+export { Default } from './Default.stories.stories';
+export { FocusRestoreHistory } from './FocusRestoreHistory.stories';
+export { UserRestoreFocus } from './UserRestoreFocus.stories.stories';
+
+export default {
+ title: 'Utilities/Focus Management/useRestoreFocusSource',
+ component: useRestoreFocusSource,
+ parameters: {
+ docs: {
+ description: {
+ component: [descriptionMd].join('\n'),
+ },
+ },
+ },
+};
diff --git a/packages/react-components/react-components/stories/Concepts/FocusManagement/useRestoreFocusSource/useRestoreFocusSourceDescription.md b/packages/react-components/react-components/stories/Concepts/FocusManagement/useRestoreFocusSource/useRestoreFocusSourceDescription.md
new file mode 100644
index 0000000000000..48e185b2990bb
--- /dev/null
+++ b/packages/react-components/react-components/stories/Concepts/FocusManagement/useRestoreFocusSource/useRestoreFocusSourceDescription.md
@@ -0,0 +1,10 @@
+The hooks `useRestoreFocusSource` and `useRestoreFocusTarget` are intended to be used together, but without tight
+coupling.
+
+When the attribute returned by `useRestoreFocusSource` is applied to an element, it will be ready to restore focus
+to the last 'bookmarked' element that was set using `useRestoreFocusTarget`. The restore focus target
+**needs to be focused** before focus is lost from a source. This is to prevent focus randomly jumping across
+an application but being restored to the an element at the closest point in time.
+
+The examples below simulate a feedback experience. One a user submits feedback, the control will be removed from
+the page and the focus will need to revert from the body (since the focused element was removed).
diff --git a/packages/react-components/react-dialog/src/components/DialogTrigger/DialogTrigger.test.tsx b/packages/react-components/react-dialog/src/components/DialogTrigger/DialogTrigger.test.tsx
index e515177f2691e..82020389d93f1 100644
--- a/packages/react-components/react-dialog/src/components/DialogTrigger/DialogTrigger.test.tsx
+++ b/packages/react-components/react-dialog/src/components/DialogTrigger/DialogTrigger.test.tsx
@@ -56,7 +56,7 @@ describe('DialogTrigger', () => {
expect(ref.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Trigger
,
@@ -85,7 +85,7 @@ describe('DialogTrigger', () => {
expect(cb.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Trigger
,
diff --git a/packages/react-components/react-dialog/src/components/DialogTrigger/__snapshots__/DialogTrigger.test.tsx.snap b/packages/react-components/react-dialog/src/components/DialogTrigger/__snapshots__/DialogTrigger.test.tsx.snap
index fd096629701b7..6e0f1d54bc456 100644
--- a/packages/react-components/react-dialog/src/components/DialogTrigger/__snapshots__/DialogTrigger.test.tsx.snap
+++ b/packages/react-components/react-dialog/src/components/DialogTrigger/__snapshots__/DialogTrigger.test.tsx.snap
@@ -2,7 +2,7 @@
exports[`DialogTrigger renders a default state 1`] = `
Dialog trigger
diff --git a/packages/react-components/react-dialog/src/components/DialogTrigger/useDialogTrigger.ts b/packages/react-components/react-dialog/src/components/DialogTrigger/useDialogTrigger.ts
index 827b4ff31f013..75a3be91d3615 100644
--- a/packages/react-components/react-dialog/src/components/DialogTrigger/useDialogTrigger.ts
+++ b/packages/react-components/react-dialog/src/components/DialogTrigger/useDialogTrigger.ts
@@ -3,6 +3,7 @@ import { applyTriggerPropsToChildren, getTriggerChild, useEventCallback } from '
import type { DialogTriggerProps, DialogTriggerState } from './DialogTrigger.types';
import { useDialogContext_unstable, useDialogSurfaceContext_unstable } from '../../contexts';
import { useARIAButtonProps } from '@fluentui/react-aria';
+import { useModalAttributes } from '@fluentui/react-tabster';
/**
* Create the state required to render DialogTrigger.
@@ -18,7 +19,7 @@ export const useDialogTrigger_unstable = (props: DialogTriggerProps): DialogTrig
const child = getTriggerChild(children);
const requestOpenChange = useDialogContext_unstable(ctx => ctx.requestOpenChange);
- const triggerAttributes = useDialogContext_unstable(ctx => ctx.triggerAttributes);
+ const { triggerAttributes } = useModalAttributes();
const handleClick = useEventCallback(
(event: React.MouseEvent) => {
diff --git a/packages/react-components/react-dialog/stories/Dialog/DialogTriggerOutsideDialog.md b/packages/react-components/react-dialog/stories/Dialog/DialogTriggerOutsideDialog.md
index 8cc61c1143644..4066b5cb652ad 100644
--- a/packages/react-components/react-dialog/stories/Dialog/DialogTriggerOutsideDialog.md
+++ b/packages/react-components/react-dialog/stories/Dialog/DialogTriggerOutsideDialog.md
@@ -1,3 +1,3 @@
-`DialogTrigger` is not a component that can be used outside of `Dialog`. If you want to trigger the dialog from outside of `Dialog`, you should use controlled state instead.
-
-> ⚠️ Note: As there will be no `DialogTrigger`, you should handle focus restoration when the dialog gets closed.
+When using a `Dialog` without a `DialogTrigger`, it is up to the user to make sure that the focus is restored correctly
+when the dialog is closed. This can be done quite easily by using the `useRestoreFocusTarget` hook. The `Dialog` already
+uses the `useRestoreFocusSource` hook directly, which will restore focus to the most recently focused target on close.
diff --git a/packages/react-components/react-dialog/stories/Dialog/DialogTriggerOutsideDialog.stories.tsx b/packages/react-components/react-dialog/stories/Dialog/DialogTriggerOutsideDialog.stories.tsx
index dc51ae617b222..41fec25c7a482 100644
--- a/packages/react-components/react-dialog/stories/Dialog/DialogTriggerOutsideDialog.stories.tsx
+++ b/packages/react-components/react-dialog/stories/Dialog/DialogTriggerOutsideDialog.stories.tsx
@@ -8,33 +8,21 @@ import {
DialogTrigger,
DialogBody,
Button,
- DialogOpenChangeData,
+ useRestoreFocusTarget,
} from '@fluentui/react-components';
import story from './DialogTriggerOutsideDialog.md';
export const TriggerOutsideDialog = () => {
- const triggerRef = React.useRef(null);
-
const [open, setOpen] = React.useState(false);
- const [closeAction, setCloseAction] = React.useState(null);
-
- React.useEffect(() => {
- // Prevents focusing on an initial render
- if (open || closeAction === null) {
- return;
- }
-
- triggerRef.current?.focus();
- }, [closeAction, open]);
+ const restoreFocusTargetAttribute = useRestoreFocusTarget();
return (
<>
{
setOpen(true);
- setCloseAction(null);
}}
- ref={triggerRef}
>
Open Dialog
@@ -43,7 +31,6 @@ export const TriggerOutsideDialog = () => {
open={open}
onOpenChange={(event, data) => {
setOpen(data.open);
- setCloseAction(data.type);
}}
>
diff --git a/packages/react-components/react-menu/src/components/Menu/__snapshots__/Menu.test.tsx.snap b/packages/react-components/react-menu/src/components/Menu/__snapshots__/Menu.test.tsx.snap
index fc7e36d350dc0..c604de7dd8c5e 100644
--- a/packages/react-components/react-menu/src/components/Menu/__snapshots__/Menu.test.tsx.snap
+++ b/packages/react-components/react-menu/src/components/Menu/__snapshots__/Menu.test.tsx.snap
@@ -4,6 +4,7 @@ exports[`Menu renders a default state 1`] = `
Menu trigger
diff --git a/packages/react-components/react-menu/src/components/Menu/useMenu.tsx b/packages/react-components/react-menu/src/components/Menu/useMenu.tsx
index cf6c19f807ca0..0ea43aa3fb268 100644
--- a/packages/react-components/react-menu/src/components/Menu/useMenu.tsx
+++ b/packages/react-components/react-menu/src/components/Menu/useMenu.tsx
@@ -11,7 +11,6 @@ import {
useOnClickOutside,
useEventCallback,
useOnScrollOutside,
- useFirstMount,
} from '@fluentui/react-utilities';
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
import { elementContains } from '@fluentui/react-portal';
@@ -275,26 +274,11 @@ const useMenuOpenState = (
firstFocusable?.focus();
}, [findFirstFocusable, state.menuPopoverRef]);
- const firstMount = useFirstMount();
React.useEffect(() => {
if (open) {
focusFirst();
- } else {
- if (!firstMount) {
- if (targetDocument?.activeElement === targetDocument?.body) {
- // We know that React effects are sync so we focus the trigger here
- // after any event handler (event handlers will update state and re-render).
- // Since the browser only performs the default behaviour for the Tab key once
- // keyboard events have fully bubbled up the window, the browser will move
- // focus to the next tabbable element before/after the trigger if needed.
- // If the Tab key was not pressed, focus will remain on the trigger as expected.
- state.triggerRef.current?.focus();
- }
- }
}
- // firstMount change should not re-run this effect
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [state.triggerRef, state.isSubmenu, open, focusFirst, targetDocument, state.menuPopoverRef]);
+ }, [open, focusFirst]);
return [open, setOpen] as const;
};
diff --git a/packages/react-components/react-menu/src/components/MenuPopover/useMenuPopover.ts b/packages/react-components/react-menu/src/components/MenuPopover/useMenuPopover.ts
index c5221404070cf..7208d05e85107 100644
--- a/packages/react-components/react-menu/src/components/MenuPopover/useMenuPopover.ts
+++ b/packages/react-components/react-menu/src/components/MenuPopover/useMenuPopover.ts
@@ -6,6 +6,7 @@ import { useMenuContext_unstable } from '../../contexts/menuContext';
import { dispatchMenuEnterEvent } from '../../utils/index';
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
import { useIsSubmenu } from '../../utils/useIsSubmenu';
+import { useRestoreFocusSource } from '@fluentui/react-tabster';
/**
* Create the state required to render MenuPopover.
@@ -21,9 +22,11 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref<
const setOpen = useMenuContext_unstable(context => context.setOpen);
const open = useMenuContext_unstable(context => context.open);
const openOnHover = useMenuContext_unstable(context => context.openOnHover);
+ const triggerRef = useMenuContext_unstable(context => context.triggerRef);
const isSubmenu = useIsSubmenu();
const canDispatchCustomEventRef = React.useRef(true);
const throttleDispatchTimerRef = React.useRef(0);
+ const restoreFocusSourceAttributes = useRestoreFocusSource();
const { dir } = useFluent();
const CloseArrowKey = dir === 'ltr' ? ArrowLeft : ArrowRight;
@@ -59,6 +62,7 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref<
const rootProps = getNativeElementProps('div', {
role: 'presentation',
+ ...restoreFocusSourceAttributes,
...props,
ref: useMergedRefs(ref, popoverRef, mouseOverListenerCallbackRef),
});
@@ -87,6 +91,9 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref<
if (key === Tab) {
setOpen(event, { open: false, keyboard: true, type: 'menuPopoverKeyDown', event });
+ if (!isSubmenu) {
+ triggerRef.current?.focus();
+ }
}
onKeyDownOriginal?.(event);
diff --git a/packages/react-components/react-menu/src/components/MenuTrigger/MenuTrigger.test.tsx b/packages/react-components/react-menu/src/components/MenuTrigger/MenuTrigger.test.tsx
index 4a55ade8fc0de..240ae1f22a9a7 100644
--- a/packages/react-components/react-menu/src/components/MenuTrigger/MenuTrigger.test.tsx
+++ b/packages/react-components/react-menu/src/components/MenuTrigger/MenuTrigger.test.tsx
@@ -57,6 +57,7 @@ describe('MenuTrigger', () => {
Array [
Trigger
@@ -87,6 +88,7 @@ describe('MenuTrigger', () => {
Array [
Trigger
diff --git a/packages/react-components/react-menu/src/components/MenuTrigger/MenuTrigger.tsx b/packages/react-components/react-menu/src/components/MenuTrigger/MenuTrigger.tsx
index f33a6edd402c5..6b380e0dd3687 100644
--- a/packages/react-components/react-menu/src/components/MenuTrigger/MenuTrigger.tsx
+++ b/packages/react-components/react-menu/src/components/MenuTrigger/MenuTrigger.tsx
@@ -1,5 +1,5 @@
import * as React from 'react';
-import { useMenuTrigger_unstable } from './useMenuTrigger.styles';
+import { useMenuTrigger_unstable } from './useMenuTrigger';
import { renderMenuTrigger_unstable } from './renderMenuTrigger';
import type { MenuTriggerProps } from './MenuTrigger.types';
import type { FluentTriggerComponent } from '@fluentui/react-utilities';
diff --git a/packages/react-components/react-menu/src/components/MenuTrigger/__snapshots__/MenuTrigger.test.tsx.snap b/packages/react-components/react-menu/src/components/MenuTrigger/__snapshots__/MenuTrigger.test.tsx.snap
index 7826501c5a172..8d08c72a08737 100644
--- a/packages/react-components/react-menu/src/components/MenuTrigger/__snapshots__/MenuTrigger.test.tsx.snap
+++ b/packages/react-components/react-menu/src/components/MenuTrigger/__snapshots__/MenuTrigger.test.tsx.snap
@@ -3,6 +3,7 @@
exports[`MenuTrigger renders a default state 1`] = `
context.triggerId);
const openOnHover = useMenuContext_unstable(context => context.openOnHover);
const openOnContext = useMenuContext_unstable(context => context.openOnContext);
+ const restoreFocusTargetAttribute = useRestoreFocusTarget();
const isSubmenu = useIsSubmenu();
@@ -135,6 +136,7 @@ export const useMenuTrigger_unstable = (props: MenuTriggerProps): MenuTriggerSta
const triggerChildProps = {
'aria-haspopup': 'menu',
'aria-expanded': !open && !isSubmenu ? undefined : open,
+ ...restoreFocusTargetAttribute,
...contextMenuProps,
onClick: useEventCallback(mergeCallbacks(child?.props.onClick, onClick)),
onKeyDown: useEventCallback(mergeCallbacks(child?.props.onKeyDown, onKeyDown)),
diff --git a/packages/react-components/react-menu/stories/Menu/MenuAnchorToTarget.stories.tsx b/packages/react-components/react-menu/stories/Menu/MenuAnchorToTarget.stories.tsx
index 90861dbf3656b..cbb186374ae13 100644
--- a/packages/react-components/react-menu/stories/Menu/MenuAnchorToTarget.stories.tsx
+++ b/packages/react-components/react-menu/stories/Menu/MenuAnchorToTarget.stories.tsx
@@ -1,6 +1,6 @@
import * as React from 'react';
-import { Button, Menu, MenuList, MenuItem, MenuPopover } from '@fluentui/react-components';
+import { Button, Menu, MenuList, MenuItem, MenuPopover, useRestoreFocusTarget } from '@fluentui/react-components';
import type { MenuProps, PositioningImperativeRef } from '@fluentui/react-components';
export const AnchorToCustomTarget = () => {
@@ -17,10 +17,14 @@ export const AnchorToCustomTarget = () => {
}
}, [buttonRef, positioningRef]);
+ const restoreFocusTargetAttribute = useRestoreFocusTarget();
+
return (
<>
- setOpen(s => !s)}>Open menu
- setOpen(s => !s)}>
+ setOpen(s => !s)}>
+ Open menu
+
+ setOpen(s => !s)}>
Custom target