diff --git a/packages/react-components/react-dialog/stories/src/Dialog/DialogBestPractices.md b/packages/react-components/react-dialog/stories/src/Dialog/DialogBestPractices.md index 3717da47e72eff..f55e2bc61cfe42 100644 --- a/packages/react-components/react-dialog/stories/src/Dialog/DialogBestPractices.md +++ b/packages/react-components/react-dialog/stories/src/Dialog/DialogBestPractices.md @@ -4,12 +4,12 @@ - Dialog boxes consist of a header (`DialogTitle`), content (`DialogContent`), and footer (`DialogActions`), which should all be included inside a body (`DialogBody`). - Validate that people’s entries are acceptable before closing the dialog. Show an inline validation error near the field they must correct. -- Modal dialogs should be used very sparingly—only when it’s critical that people make a choice or provide information before they can proceed. Thee dialogs are generally used for irreversible or potentially destructive tasks. They’re typically paired with an backdrop without a light dismiss. +- Modal dialogs should be used very sparingly—only when it’s critical that people make a choice or provide information before they can proceed. These dialogs are generally used for irreversible or potentially destructive tasks. They’re typically paired with a backdrop without a light dismiss. - Add a `aria-describedby` attribute on `DialogSurface` pointing to the dialog content on short confirmation like dialogs. - Add a `aria-label` or `aria-labelledby` attribute on `DialogSurface` if there is no `DialogTitle` ### Don't - Don't use more than three buttons between `DialogActions`. -- Don't open a `Dialog` from a `Dialog` +- Don't open nested `Dialog`s without proper focus management. Use `DialogTrigger` for automatic focus restoration when dialogs are opened by user interaction, or use `useRestoreFocusTarget()` when opening dialogs programmatically. `DialogSurface` already provides restore-focus source attributes when used inside `Dialog`. See the [Nested Dialogs](https://react.fluentui.dev/?path=/docs/components-dialog--docs#nested-dialogs-with-trigger) example for details. - Don't use a `Dialog` with no focusable elements diff --git a/packages/react-components/react-dialog/stories/src/Dialog/DialogNestedDialogs.md b/packages/react-components/react-dialog/stories/src/Dialog/DialogNestedDialogs.md new file mode 100644 index 00000000000000..d3c0028b481416 --- /dev/null +++ b/packages/react-components/react-dialog/stories/src/Dialog/DialogNestedDialogs.md @@ -0,0 +1,95 @@ +# Nested Dialogs + +Nested dialogs (opening a dialog from within another dialog) are an anti-pattern and should be avoided whenever possible. +If you must use them, ensure focus management is correct to keep keyboard navigation predictable and accessible. + +## Key Principle + +When using nested dialogs, **prefer `DialogTrigger` for focus restoration**. If you open a dialog programmatically without `DialogTrigger`, you become responsible for managing open state and focus restoration. + +## Recommended: Using DialogTrigger + +Use `DialogTrigger` for opening nested dialogs. This provides automatic focus restoration when dialogs close: + +```tsx + + + + + + + Outer Dialog + + + + + {/* Inner dialog content */} + + + + +``` + +**Benefits:** + +- Focus is automatically restored when dialogs close +- Simpler to implement +- No manual focus management needed + +## Programmatic Control: Managing open state and focus + +If you must open dialogs programmatically (without user click), use `useRestoreFocusTarget()` on the elements that open each dialog. Note that you are responsible for ensuring focus is correctly restored: + +```tsx +const [outerOpen, setOuterOpen] = React.useState(false); +const [innerOpen, setInnerOpen] = React.useState(false); + +const outerTargetAttrs = useRestoreFocusTarget(); +const innerTargetAttrs = useRestoreFocusTarget(); + +return ( + <> + + setOuterOpen(data.open)}> + + + + setInnerOpen(data.open)}> + {/* Inner dialog content */} + + + + +); +``` + +`DialogSurface` already provides restore-focus source attributes internally when used inside `Dialog`. + +**How it works:** + +- `useRestoreFocusTarget()` attributes go on the button/element that opens the dialog +- When a dialog closes, focus returns to the element with `useRestoreFocusTarget()` attributes +- Each dialog opener should have its own restore-focus target + +For a complete programmatic-open example, see [Trigger outside Dialog](https://react.fluentui.dev/?path=/docs/components-dialog--docs#trigger-outside-dialog). + +## Best Practices + +1. **Avoid nested dialogs when possible** - Prefer simpler flows when the design allows it +2. **Use DialogTrigger by default** - It provides automatic focus restoration for user-triggered opens +3. **Use restore-focus targets for programmatic dialogs** - Add `useRestoreFocusTarget()` to elements that open dialogs +4. **Test with keyboard navigation** - Verify that Escape key and backdrop clicks work correctly, and Tab/Shift+Tab navigate through the dialog stack + +## Accessibility + +Proper focus management in nested dialogs is crucial for: + +- **Keyboard users** - They can close dialogs with Escape and navigate through the dialog stack using Tab +- **Screen reader users** - Focus announcements help users understand which dialog is active +- **Motor control users** - They depend on consistent focus behavior for reliable navigation + +See [focus management utilities documentation](https://react.fluentui.dev/?path=/docs/utilities-focus-management-userestorefocussource--docs) for more details on focus management utilities. diff --git a/packages/react-components/react-dialog/stories/src/Dialog/DialogNestedDialogsWithTrigger.stories.tsx b/packages/react-components/react-dialog/stories/src/Dialog/DialogNestedDialogsWithTrigger.stories.tsx new file mode 100644 index 00000000000000..621ac827239db7 --- /dev/null +++ b/packages/react-components/react-dialog/stories/src/Dialog/DialogNestedDialogsWithTrigger.stories.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { + Dialog, + DialogTrigger, + DialogSurface, + DialogTitle, + DialogBody, + DialogContent, + DialogActions, + Button, +} from '@fluentui/react-components'; +import story from './DialogNestedDialogs.md'; + +export const NestedDialogsWithTrigger = (): JSXElement => { + return ( + + + + + + + + Outer Dialog + + This is the outer dialog. Click the button below to open a nested dialog. When using DialogTrigger, focus is + automatically restored. + + + + + + + + + + Inner Dialog + + This is a nested dialog inside the outer dialog. Focus will automatically be restored to the Open + Inner Dialog button when this one closes thanks to DialogTrigger. + + + + + + + + + + + + + + + + + + + ); +}; + +NestedDialogsWithTrigger.parameters = { + docs: { + description: { + story, + }, + }, +}; diff --git a/packages/react-components/react-dialog/stories/src/Dialog/DialogTriggerOutsideDialog.md b/packages/react-components/react-dialog/stories/src/Dialog/DialogTriggerOutsideDialog.md index d0ff162cbbe8a0..50aec58904ec40 100644 --- a/packages/react-components/react-dialog/stories/src/Dialog/DialogTriggerOutsideDialog.md +++ b/packages/react-components/react-dialog/stories/src/Dialog/DialogTriggerOutsideDialog.md @@ -1,6 +1,11 @@ -When using a `Dialog` without a `DialogTrigger` (or when using a `DialogTrigger` outside of a `Dialog`), it becomes your responsibility to control some of the dialog's behavior. +When using a `Dialog` without a `DialogTrigger`, you become responsible for managing the dialog's behavior. This applies to: -1. You must make sure that the `open` state is set accordingly to the dialog's visibility (mostly this means to properly react to the events provided by `onOpenChange` callback on `Dialog` component). -2. You must make sure that focus is properly restored once the dialog is closed (this can be achieved by using the `useRestoreFocusTarget` hook, or by manually invoking `.focus()` on the target element). +- Opening dialogs programmatically (via state, API calls, side effects) +- Opening nested dialogs where the inner dialog is not wrapped in a `DialogTrigger` -The example bellow showcases both explicit responsibilities: +**Your responsibilities:** + +1. **Control the open state** - React to the `onOpenChange` callback and ensure the `open` state reflects the dialog's visibility +2. **Restore focus** - When the dialog closes, you must restore focus to the element that triggered the open. Use `useRestoreFocusTarget` on the trigger element, or manually invoke `.focus()` on the target element. `DialogSurface` already applies the restore-focus source attributes internally when used inside `Dialog`. + +The example below showcases both responsibilities: diff --git a/packages/react-components/react-dialog/stories/src/Dialog/index.stories.tsx b/packages/react-components/react-dialog/stories/src/Dialog/index.stories.tsx index 653aa7e52a2518..2d47b0c5acce7a 100644 --- a/packages/react-components/react-dialog/stories/src/Dialog/index.stories.tsx +++ b/packages/react-components/react-dialog/stories/src/Dialog/index.stories.tsx @@ -23,6 +23,7 @@ export { TitleCustomAction } from './DialogTitleCustomAction.stories'; export { TitleNoAction } from './DialogTitleNoAction.stories'; export { Confirmation } from './DialogConfirmation.stories'; export { MotionCustom } from './DialogMotionCustom.stories'; +export { NestedDialogsWithTrigger } from './DialogNestedDialogsWithTrigger.stories'; // Typing with Meta generates a type error for the `subcomponents` property. // https://github.com/storybookjs/storybook/issues/27535