Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
Expand Up @@ -11,5 +11,5 @@
### 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. If you need nested dialogs, use `DialogTrigger` for automatic focus restoration or `useRestoreFocusSource()` and `useRestoreFocusTarget()` hooks for programmatic control. See the [Nested Dialogs](/docs/components-dialog--nested-dialogs) example for details.
Comment thread
paolo-aliprandi marked this conversation as resolved.
Outdated
- Don't use a `Dialog` with no focusable elements
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Nested Dialogs
Comment thread
paolo-aliprandi marked this conversation as resolved.

When implementing nested dialogs (a dialog opened from within another dialog), proper focus management is critical to ensure users can navigate back through the dialog stack correctly.

## Important: Nested Dialogs Should Be Closed Programmatically

Nested dialogs should **always be closed programmatically** through state management, not manually by clicking outside or pressing Escape. This ensures predictable focus behavior and a better user experience.
Comment thread
paolo-aliprandi marked this conversation as resolved.
Outdated

## Two Approaches

### Approach 1: Using DialogTrigger (Recommended for User-Triggered Opens)

If the nested dialog is opened by a button click (user interaction), use `DialogTrigger` for automatic focus restoration:

```tsx
<Dialog>
<DialogTrigger disableButtonEnhancement>
<Button>Open Outer Dialog</Button>
</DialogTrigger>
<DialogSurface>
<DialogBody>
<DialogTitle>Outer Dialog</DialogTitle>
<Dialog>
<DialogTrigger disableButtonEnhancement>
<Button>Open Inner Dialog</Button>
</DialogTrigger>
<DialogSurface>{/* Inner dialog content */}</DialogSurface>
</Dialog>
</DialogBody>
</DialogSurface>
</Dialog>
```

**Benefits:**

- Focus is automatically restored when dialogs close
- Simpler to implement
- No need for manual focus management hooks

### Approach 2: Using Focus Restoration Hooks (For Programmatic Control)

If the nested dialog is opened programmatically (not triggered by user interaction), use `useRestoreFocusSource()` and `useRestoreFocusTarget()` hooks:

```tsx
const [outerOpen, setOuterOpen] = React.useState(false);
const [innerOpen, setInnerOpen] = React.useState(false);

const outerSourceAttrs = useRestoreFocusSource();
const outerTargetAttrs = useRestoreFocusTarget();
const innerSourceAttrs = useRestoreFocusSource();
const innerTargetAttrs = useRestoreFocusTarget();

return (
<Dialog open={outerOpen} onOpenChange={(e, data) => setOuterOpen(data.open)}>
<Button {...outerTargetAttrs} onClick={() => setOuterOpen(true)}>
Open Outer Dialog
</Button>
<DialogSurface {...outerSourceAttrs}>
<Button {...innerTargetAttrs} onClick={() => setInnerOpen(true)}>
Comment thread
paolo-aliprandi marked this conversation as resolved.
Outdated
Open Inner Dialog
</Button>
</DialogSurface>

<Dialog open={innerOpen} onOpenChange={(e, data) => setInnerOpen(data.open)}>
<DialogSurface {...innerSourceAttrs}>{/* Inner dialog content */}</DialogSurface>
</Dialog>
Comment thread
paolo-aliprandi marked this conversation as resolved.
Outdated
</Dialog>
);
```

**How it works:**

- `useRestoreFocusTarget()` attributes go on the button/element that opens the dialog
- `useRestoreFocusSource()` attributes go on the DialogSurface
- When the dialog closes, focus returns to the element with `useRestoreFocusTarget()` attributes
- Each dialog pair (source/target) manages its own focus restoration

## Best Practices

1. **Use DialogTrigger by default** - It provides automatic focus restoration for user-triggered opens
2. **Use focus hooks for programmatic dialogs** - When you open dialogs from code (not user clicks), use the focus restoration hooks
3. **Always apply focus attributes** - Don't skip focus management; it's essential for accessibility
4. **Close dialogs programmatically** - Don't rely on manual close (clicking outside, pressing Escape) for nested dialogs
5. **Test with keyboard navigation** - Verify that Tab and Shift+Tab work correctly through the dialog stack

## Accessibility

Proper focus management in nested dialogs is crucial for:

- **Keyboard users** - They can navigate back 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 [useRestoreFocusSource hook documentation](/docs/utilities-focus-management-userestorefocussource--docs) for more details on focus management utilities.
Comment thread
paolo-aliprandi marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as React from 'react';
import type { JSXElement } from '@fluentui/react-components';
import {
Dialog,
DialogSurface,
DialogTitle,
DialogBody,
DialogContent,
DialogActions,
Button,
useRestoreFocusSource,
useRestoreFocusTarget,
} from '@fluentui/react-components';
import story from './DialogNestedDialogs.md';

export const NestedDialogs = (): JSXElement => {
const [outerOpen, setOuterOpen] = React.useState(false);
const [innerOpen, setInnerOpen] = React.useState(false);

// Focus restoration for outer dialog
const outerRestoreFocusSourceAttributes = useRestoreFocusSource();
const outerRestoreFocusTargetAttributes = useRestoreFocusTarget();

// Focus restoration for inner dialog
const innerRestoreFocusSourceAttributes = useRestoreFocusSource();
const innerRestoreFocusTargetAttributes = useRestoreFocusTarget();

return (
<div>
{/* Outer Dialog */}
<Dialog open={outerOpen} onOpenChange={(event, data) => setOuterOpen(data.open)}>
<Button {...outerRestoreFocusTargetAttributes} appearance="primary" onClick={() => setOuterOpen(true)}>
Open Outer Dialog
</Button>

<DialogSurface {...outerRestoreFocusSourceAttributes}>
<DialogBody>
<DialogTitle>Outer Dialog</DialogTitle>
<DialogContent>This is the outer dialog. Click the button below to open a nested dialog.</DialogContent>
<DialogActions>
<Button {...innerRestoreFocusTargetAttributes} appearance="primary" onClick={() => setInnerOpen(true)}>
Open Inner Dialog
</Button>
<Button appearance="secondary" onClick={() => setOuterOpen(false)}>
Close Outer Dialog
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>

{/* Inner Dialog */}
<Dialog open={innerOpen} onOpenChange={(event, data) => setInnerOpen(data.open)}>
<DialogSurface {...innerRestoreFocusSourceAttributes}>
<DialogBody>
<DialogTitle>Inner Dialog</DialogTitle>
<DialogContent>
This is a nested dialog inside the outer dialog. Focus will be restored to the outer dialog when this one
closes.
</DialogContent>
<DialogActions>
<Button appearance="primary">Confirm</Button>
<Button appearance="secondary" onClick={() => setInnerOpen(false)}>
Close Inner Dialog
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
</div>
);
};

NestedDialogs.parameters = {
docs: {
description: {
story,
},
},
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We used to have a story for Nested dialogs usage before also. It was removed since, even though we clearly stated that is a bad pattern, people kept using it all the time. Our conclusion was: if this is a bad pattern we should most likely not be explaining how to "do it properly", it's an anti-pattern and we discourage it, find another solution.

I would keep it as a section and an explanation, but I would avoid providing an example entirely, just to discourage even more the usage of it.

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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';

export const NestedDialogsWithTrigger = (): JSXElement => {
return (
<Dialog>
<DialogTrigger disableButtonEnhancement>
<Button appearance="primary">Open Outer Dialog</Button>
</DialogTrigger>

<DialogSurface>
<DialogBody>
<DialogTitle>Outer Dialog</DialogTitle>
<DialogContent>
This is the outer dialog. Click the button below to open a nested dialog. When using DialogTrigger, focus is
automatically restored.
</DialogContent>
<DialogActions>
<Dialog>
<DialogTrigger disableButtonEnhancement>
<Button appearance="primary">Open Inner Dialog</Button>
</DialogTrigger>

<DialogSurface>
<DialogBody>
<DialogTitle>Inner Dialog</DialogTitle>
<DialogContent>
This is a nested dialog inside the outer dialog. Focus will automatically be restored to the outer
dialog when this one closes thanks to DialogTrigger.
Comment thread
paolo-aliprandi marked this conversation as resolved.
Outdated
</DialogContent>
<DialogActions>
<Button appearance="primary">Confirm</Button>
<DialogTrigger disableButtonEnhancement>
<Button appearance="secondary">Close Inner Dialog</Button>
</DialogTrigger>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>

<DialogTrigger disableButtonEnhancement>
<Button appearance="secondary">Close Outer Dialog</Button>
</DialogTrigger>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
};

NestedDialogsWithTrigger.parameters = {
docs: {
description: {
story: [
'Using DialogTrigger for nested dialogs provides automatic focus restoration.',
'This is the simpler and recommended approach when the dialogs are opened by user interaction.',
'Focus management is handled automatically without needing useRestoreFocusSource and useRestoreFocusTarget hooks.',
].join('\n'),
},
},
Comment thread
paolo-aliprandi marked this conversation as resolved.
};
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export { TitleCustomAction } from './DialogTitleCustomAction.stories';
export { TitleNoAction } from './DialogTitleNoAction.stories';
export { Confirmation } from './DialogConfirmation.stories';
export { MotionCustom } from './DialogMotionCustom.stories';
export { NestedDialogs } from './DialogNestedDialogs.stories';
export { NestedDialogsWithTrigger } from './DialogNestedDialogsWithTrigger.stories';
Comment thread
paolo-aliprandi marked this conversation as resolved.

// Typing with Meta<typeof Dialog> generates a type error for the `subcomponents` property.
// https://github.com/storybookjs/storybook/issues/27535
Expand Down
Loading