main
js 186 lines 4.28 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import type {ReactContext} from 'shared/ReactTypes';
11 import * as React from 'react';
12 import {
13 createContext,
14 useCallback,
15 useContext,
16 useMemo,
17 useReducer,
18 useRef,
19 } from 'react';
20 import Button from './Button';
21 import {useModalDismissSignal} from './hooks';
22
23 import styles from './ModalDialog.css';
24
25 type ID = any;
26
27 type DIALOG_ACTION_HIDE = {
28 type: 'HIDE',
29 id: ID,
30 };
31 type DIALOG_ACTION_SHOW = {
32 type: 'SHOW',
33 canBeDismissed?: boolean,
34 content: React$Node,
35 id: ID,
36 title?: React$Node | null,
37 };
38
39 type Action = DIALOG_ACTION_HIDE | DIALOG_ACTION_SHOW;
40
41 type Dispatch = (action: Action) => void;
42
43 type Dialog = {
44 canBeDismissed: boolean,
45 content: React$Node | null,
46 id: ID,
47 title: React$Node | null,
48 };
49
50 type State = {
51 dialogs: Array<Dialog>,
52 };
53
54 type ModalDialogContextType = {
55 ...State,
56 dispatch: Dispatch,
57 };
58
59 const ModalDialogContext: ReactContext<ModalDialogContextType> =
60 createContext<ModalDialogContextType>(null as any as ModalDialogContextType);
61 ModalDialogContext.displayName = 'ModalDialogContext';
62
63 function dialogReducer(state: State, action: Action) {
64 switch (action.type) {
65 case 'HIDE':
66 return {
67 dialogs: state.dialogs.filter(dialog => dialog.id !== action.id),
68 };
69 case 'SHOW':
70 return {
71 dialogs: [
72 ...state.dialogs,
73 {
74 canBeDismissed: action.canBeDismissed !== false,
75 content: action.content,
76 id: action.id,
77 title: action.title || null,
78 } as Dialog,
79 ],
80 };
81 default:
82 throw new Error(`Invalid action "${action.type}"`);
83 }
84 }
85
86 type Props = {
87 children: React$Node,
88 };
89
90 function ModalDialogContextController({children}: Props): React.Node {
91 const [state, dispatch] = useReducer<State, State, Action>(dialogReducer, {
92 dialogs: [],
93 });
94
95 const value = useMemo<ModalDialogContextType>(
96 () => ({
97 dialogs: state.dialogs,
98 dispatch,
99 }),
100 [state, dispatch],
101 );
102
103 return (
104 <ModalDialogContext.Provider value={value}>
105 {children}
106 </ModalDialogContext.Provider>
107 );
108 }
109
110 function ModalDialog(_: {}): React.Node {
111 const {dialogs, dispatch} = useContext(ModalDialogContext);
112
113 if (dialogs.length === 0) {
114 return null;
115 }
116
117 return (
118 <div className={styles.Background}>
119 {dialogs.map(dialog => (
120 <ModalDialogImpl
121 key={dialog.id}
122 canBeDismissed={dialog.canBeDismissed}
123 content={dialog.content}
124 dispatch={dispatch}
125 id={dialog.id}
126 title={dialog.title}
127 />
128 ))}
129 </div>
130 );
131 }
132
133 function ModalDialogImpl({
134 canBeDismissed,
135 content,
136 dispatch,
137 id,
138 title,
139 }: {
140 canBeDismissed: boolean,
141 content: React$Node | null,
142 dispatch: Dispatch,
143 id: ID,
144 title: React$Node | null,
145 }) {
146 const dismissModal = useCallback(() => {
147 if (canBeDismissed) {
148 dispatch({type: 'HIDE', id});
149 }
150 }, [canBeDismissed, dispatch]);
151 const dialogRef = useRef<HTMLDivElement | null>(null);
152
153 // It's important to trap click events within the dialog,
154 // so the dismiss hook will use it for click hit detection.
155 // Because multiple tabs may be showing this ModalDialog,
156 // the normal `dialog.contains(target)` check would fail on a background tab.
157 useModalDismissSignal(dialogRef, dismissModal, false);
158
159 // Clicks on the dialog should not bubble.
160 // This way we can dismiss by listening to clicks on the background.
161 const handleDialogClick = (event: any) => {
162 event.stopPropagation();
163
164 // It is important that we don't also prevent default,
165 // or clicks within the dialog (e.g. on links) won't work.
166 };
167
168 return (
169 <div ref={dialogRef} className={styles.Dialog} onClick={handleDialogClick}>
170 {title !== null && <div className={styles.Title}>{title}</div>}
171 {content}
172 {canBeDismissed && (
173 <div className={styles.Buttons}>
174 <Button
175 autoFocus={true}
176 className={styles.Button}
177 onClick={dismissModal}>
178 Okay
179 </Button>
180 </div>
181 )}
182 </div>
183 );
184 }
185
186 export {ModalDialog, ModalDialogContext, ModalDialogContextController};