Skip to content

Commit 3385b6d

Browse files
devongovettdannify
andauthored
Add DialogContainer component to support controlled dialogs (#1082)
* Add DialogContainer component for controlled modals * Add docs for DialogContainer Co-authored-by: Danni <[email protected]>
1 parent 9e1d8d6 commit 3385b6d

File tree

17 files changed

+820
-74
lines changed

17 files changed

+820
-74
lines changed

packages/@react-aria/overlays/src/useModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const Context = React.createContext<ModalContext | null>(null);
3737
export function ModalProvider(props: ModalProviderProps) {
3838
let {children} = props;
3939
let parent = useContext(Context);
40-
let [modalCount, setModalCount] = useState(parent ? parent.modalCount : 0);
40+
let [modalCount, setModalCount] = useState(0);
4141
let context = useMemo(() => ({
4242
parent,
4343
modalCount,

packages/@react-spectrum/actiongroup/test/ActionGroup.test.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ function renderComponentWithExtraInputs(props) {
130130
}
131131

132132
describe('ActionGroup', function () {
133+
beforeAll(function () {
134+
jest.useFakeTimers();
135+
});
136+
133137
afterEach(() => {
134138
btnBehavior.reset();
135139
});
@@ -600,10 +604,22 @@ describe('ActionGroup', function () {
600604
);
601605

602606
let button = tree.getByRole('button');
603-
triggerPress(button);
607+
608+
act(() => {
609+
triggerPress(button);
610+
jest.runAllTimers();
611+
});
604612

605613
let dialog = tree.getByRole('dialog');
606614
expect(dialog).toBeVisible();
615+
616+
act(() => {
617+
fireEvent.keyDown(dialog, {key: 'Escape'});
618+
fireEvent.keyUp(dialog, {key: 'Escape'});
619+
jest.runAllTimers();
620+
});
621+
622+
expect(() => tree.getByRole('dialog')).toThrow();
607623
});
608624

609625
it('supports TooltipTrigger as a wrapper around items', function () {
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
<!-- Copyright 2020 Adobe. All rights reserved.
2+
This file is licensed to you under the Apache License, Version 2.0 (the "License");
3+
you may not use this file except in compliance with the License. You may obtain a copy
4+
of the License at http://www.apache.org/licenses/LICENSE-2.0
5+
Unless required by applicable law or agreed to in writing, software distributed under
6+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
7+
OF ANY KIND, either express or implied. See the License for the specific language
8+
governing permissions and limitations under the License. -->
9+
10+
import {Layout} from '@react-spectrum/docs';
11+
export default Layout;
12+
13+
import docs from 'docs:@react-spectrum/dialog';
14+
import {ExampleImage, HeaderInfo, PropTable, FunctionAPI} from '@react-spectrum/docs';
15+
import packageData from '@react-spectrum/dialog/package.json';
16+
17+
```jsx import
18+
import {Content, Footer, Header} from '@react-spectrum/view';
19+
import {Form} from '@react-spectrum/form';
20+
import {Heading, Text} from '@react-spectrum/text';
21+
import {TextField} from '@react-spectrum/textfield';
22+
import {Divider} from '@react-spectrum/divider';
23+
import {DialogContainer, Dialog, AlertDialog} from '@react-spectrum/dialog';
24+
import {ActionButton, Button} from '@react-spectrum/button';
25+
import {ButtonGroup} from '@react-spectrum/buttongroup';
26+
import {MenuTrigger, Menu, Item} from '@react-spectrum/menu';
27+
import More from '@spectrum-icons/workflow/More';
28+
import Delete from '@spectrum-icons/workflow/Delete';
29+
import Edit from '@spectrum-icons/workflow/Edit';
30+
```
31+
32+
---
33+
category: Overlays
34+
keywords: [dialog container, dialog, modal]
35+
after_version: 3.3.0
36+
---
37+
38+
# DialogContainer
39+
40+
<p>{docs.exports.DialogContainer.description}</p>
41+
42+
<HeaderInfo
43+
packageData={packageData}
44+
componentNames={['DialogContainer', 'Dialog']} />
45+
46+
## Example
47+
48+
```tsx example
49+
function Example(props) {
50+
let [isOpen, setOpen] = React.useState(false);
51+
52+
return (
53+
<>
54+
<ActionButton onPress={() => setOpen(true)}>
55+
<Delete />
56+
<Text>Delete</Text>
57+
</ActionButton>
58+
<DialogContainer onDismiss={() => setOpen(false)} {...props}>
59+
{isOpen &&
60+
<AlertDialog
61+
title="Delete"
62+
variant="destructive"
63+
primaryActionLabel="Delete">
64+
Are you sure you want to delete this item?
65+
</AlertDialog>
66+
}
67+
</DialogContainer>
68+
</>
69+
);
70+
}
71+
```
72+
73+
## Dialog triggered by a menu item
74+
75+
DialogContainer is useful over a [DialogTrigger](DialogTrigger.html) when your have a trigger that can unmount while the dialog is
76+
open. For example, placing a DialogTrigger around a menu item would not work because the menu closes when pressing an item, thereby
77+
unmounting the DialogTrigger. When the trigger unmounts, so does the Dialog. In these cases, it is useful to place the dialog *outside*
78+
the tree that unmounts, so that the dialog is not also removed.
79+
80+
The following example shows a [MenuTrigger](MenuTrigger.html) containing a [Menu](Menu.html) with two actions: "edit" and "delete".
81+
Each menu item opens a different dialog. This is implemented by using a DialogContainer that displays the edit dialog,
82+
delete dialog, or no dialog depending on the current value stored in local state. Pressing a menu item triggers the menu's
83+
`onAction` prop, which sets the state to the type of dialog to display, based on the menu item's `key`. This causes the associated
84+
dialog to be rendered within the DialogContainer.
85+
86+
This example also demonstrates the use of the `useDialogContainer` hook, which allows the dialog to dismiss itself when a user
87+
presses one of the buttons inside it. This triggers the DialogContainer's `onDismiss` event, which resets the state storing the
88+
open dialog back to `null`. In addition, the user can close the dialog using the <kbd>Escape</kbd> key (unless the
89+
`isKeyboardDismissDisabled` prop is set), or by clicking outside (if the `isDismissable` prop is set).
90+
91+
```tsx example
92+
import {useDialogContainer} from '@react-spectrum/dialog';
93+
94+
function Example() {
95+
let [dialog, setDialog] = React.useState();
96+
97+
return (
98+
<>
99+
<MenuTrigger>
100+
<ActionButton aria-label="Actions"><More /></ActionButton>
101+
<Menu onAction={setDialog}>
102+
<Item key="edit">Edit...</Item>
103+
<Item key="delete">Delete...</Item>
104+
</Menu>
105+
</MenuTrigger>
106+
<DialogContainer onDismiss={() => setDialog(null)}>
107+
{dialog === 'edit' &&
108+
<EditDialog />
109+
}
110+
{dialog === 'delete' &&
111+
<AlertDialog
112+
title="Delete"
113+
variant="destructive"
114+
primaryActionLabel="Delete">
115+
Are you sure you want to delete this item?
116+
</AlertDialog>
117+
}
118+
</DialogContainer>
119+
</>
120+
);
121+
}
122+
123+
function EditDialog() {
124+
// This hook allows us to dismiss the dialog when the user
125+
// presses one of the buttons (below).
126+
let dialog = useDialogContainer();
127+
128+
return (
129+
<Dialog>
130+
<Heading>Edit</Heading>
131+
<Divider />
132+
<Content>
133+
<Form labelPosition="side" width="100%">
134+
<TextField autoFocus label="First Name" defaultValue="John" />
135+
<TextField label="Last Name" defaultValue="Smith" />
136+
</Form>
137+
</Content>
138+
<ButtonGroup>
139+
<Button variant="secondary" onPress={dialog.dismiss}>Cancel</Button>
140+
<Button variant="cta" onPress={dialog.dismiss}>Save</Button>
141+
</ButtonGroup>
142+
</Dialog>
143+
);
144+
}
145+
```
146+
147+
## Props
148+
149+
<PropTable component={docs.exports.DialogContainer} links={docs.links} />
150+
151+
## useDialogContainer
152+
153+
The `useDialogContainer` hook can be used to allow a custom dialog component to access the `type` of container
154+
the dialog is rendered in (e.g. `modal`, `popover`, `fullscreen`, etc.), and also to dismiss the dialog
155+
programmatically. It works with both `DialogContainer` and [DialogTrigger](DialogTrigger.html).
156+
157+
<FunctionAPI function={docs.exports.useDialogContainer} links={docs.links} />
158+
159+
## Visual options
160+
161+
### Full screen
162+
163+
The `type` prop allows setting the type of modal to display. Set it to `"fullscreen"` to display a full screen dialog, which
164+
only reveals a small portion of the page behind the underlay. Use this variant for more complex workflows that do not fit in
165+
the available modal dialog [sizes](Dialog.html#size).
166+
167+
```tsx example
168+
function Example(props) {
169+
let [isOpen, setOpen] = React.useState(false);
170+
171+
return (
172+
<>
173+
<ActionButton onPress={() => setOpen(true)}>
174+
<Edit />
175+
<Text>Edit</Text>
176+
</ActionButton>
177+
<DialogContainer type="fullscreen" onDismiss={() => setOpen(false)} {...props}>
178+
{isOpen &&
179+
<EditDialog />
180+
}
181+
</DialogContainer>
182+
</>
183+
);
184+
}
185+
```
186+
187+
### Full screen takeover
188+
189+
Fullscreen takeover dialogs are similar to the fullscreen variant except that the dialog covers the entire screen.
190+
191+
```tsx example
192+
function Example(props) {
193+
let [isOpen, setOpen] = React.useState(false);
194+
195+
return (
196+
<>
197+
<ActionButton onPress={() => setOpen(true)}>
198+
<Edit />
199+
<Text>Edit</Text>
200+
</ActionButton>
201+
<DialogContainer type="fullscreenTakeover" onDismiss={() => setOpen(false)} {...props}>
202+
{isOpen &&
203+
<EditDialog />
204+
}
205+
</DialogContainer>
206+
</>
207+
);
208+
}
209+
```
210+
211+
```tsx import
212+
// Duplicated from above so we can access in other examples...
213+
function EditDialog() {
214+
let dialog = useDialogContainer();
215+
216+
return (
217+
<Dialog>
218+
<Heading>Edit</Heading>
219+
<Divider />
220+
<Content>
221+
<Form width="100%">
222+
<TextField autoFocus label="First Name" defaultValue="John" />
223+
<TextField label="Last Name" defaultValue="Smith" />
224+
</Form>
225+
</Content>
226+
<ButtonGroup>
227+
<Button variant="secondary" onPress={dialog.dismiss}>Cancel</Button>
228+
<Button variant="cta" onPress={dialog.dismiss}>Save</Button>
229+
</ButtonGroup>
230+
</Dialog>
231+
);
232+
}
233+
```
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright 2020 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {DialogContext} from './context';
14+
import {Modal} from '@react-spectrum/overlays';
15+
import React, {ReactElement, useRef} from 'react';
16+
import {SpectrumDialogContainerProps} from '@react-types/dialog';
17+
18+
/**
19+
* A DialogContainer accepts a single Dialog as a child, and manages showing and hiding
20+
* it in a modal. Useful in cases where there is no trigger element
21+
* or when the trigger unmounts while the dialog is open.
22+
*/
23+
export function DialogContainer(props: SpectrumDialogContainerProps) {
24+
let {
25+
children,
26+
type = 'modal',
27+
onDismiss,
28+
isDismissable,
29+
isKeyboardDismissDisabled
30+
} = props;
31+
32+
let childArray = React.Children.toArray(children);
33+
if (childArray.length > 1) {
34+
throw new Error('Only a single child can be passed to DialogContainer.');
35+
}
36+
37+
let lastChild = useRef<ReactElement>(null);
38+
let child = React.isValidElement(childArray[0]) ? childArray[0] : null;
39+
if (child) {
40+
lastChild.current = child;
41+
}
42+
43+
let context = {
44+
type,
45+
onClose: onDismiss,
46+
isDismissable
47+
};
48+
49+
return (
50+
<Modal
51+
isOpen={!!child}
52+
onClose={onDismiss}
53+
type={type}
54+
isDismissable={isDismissable}
55+
isKeyboardDismissDisabled={isKeyboardDismissDisabled}>
56+
<DialogContext.Provider value={context}>
57+
{lastChild.current}
58+
</DialogContext.Provider>
59+
</Modal>
60+
);
61+
}

0 commit comments

Comments
 (0)