5.7 KB181 lines
Blame
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
8import type {Deferred} from 'shared/utils';
9
10import {Button} from 'isl-components/Button';
11import {Icon} from 'isl-components/Icon';
12import {atom, useAtom, useSetAtom} from 'jotai';
13import React, {useCallback, useEffect, useRef} from 'react';
14import {defer} from 'shared/utils';
15import {useCommand} from './ISLShortcuts';
16import {Modal} from './Modal';
17import {writeAtom} from './jotaiUtils';
18
19import './useModal.css';
20
21type ButtonConfig = {label: string | React.ReactNode; primary?: boolean};
22type ModalConfigBase = {
23 /** Optional codicon to show next to the title */
24 icon?: string;
25 title?: React.ReactNode;
26 width?: string | number;
27 height?: string | number;
28 maxWidth?: string | number;
29 maxHeight?: string | number;
30 dataTestId?: string;
31};
32type ModalConfig<T> = ModalConfigBase &
33 (
34 | {
35 // hack: using 'confirm' mode requires T to be string.
36 // The type inference goes wrong if we try to add this constraint directly to the `buttons` field.
37 // By adding the constraint here, we get type checking that T is string in order to use this API.
38 type: T extends string ? 'confirm' : T extends ButtonConfig ? 'confirm' : never;
39 message: React.ReactNode;
40 buttons: ReadonlyArray<T>;
41 }
42 | {
43 type: 'custom';
44 component: (props: {returnResultAndDismiss: (data: T) => void}) => React.ReactNode;
45 }
46 );
47type ModalState<T> = {
48 config: ModalConfig<T>;
49 visible: boolean;
50 deferred: Deferred<T | undefined>;
51};
52
53const modalState = atom<ModalState<unknown | string> | null>(null);
54
55/** Wrapper around <Modal>, generated by `useModal()` hooks. */
56export function ModalContainer() {
57 const [modal, setModal] = useAtom(modalState);
58
59 // we expect at most one button is "primary"
60 const primaryButtonRef = useRef(null);
61
62 const dismiss = () => {
63 if (modal?.visible) {
64 modal.deferred.resolve(undefined);
65 setModal({...modal, visible: false});
66 }
67 };
68
69 useCommand('Escape', dismiss);
70
71 // focus primary button on mount
72 useEffect(() => {
73 if (modal?.visible && primaryButtonRef.current != null) {
74 (primaryButtonRef.current as HTMLButtonElement).focus();
75 }
76 }, [primaryButtonRef, modal?.visible]);
77
78 if (modal?.visible !== true) {
79 return null;
80 }
81
82 let content;
83 if ((modal.config as ModalConfig<string>).type === 'confirm') {
84 const config = modal.config as ModalConfig<string> & {type: 'confirm'};
85 content = (
86 <>
87 <div id="use-modal-message">{config.message}</div>
88 <div className="use-modal-buttons">
89 {config.buttons.map((button: string | ButtonConfig, index: number) => {
90 const label = typeof button === 'object' ? button.label : button;
91 const isPrimary = typeof button === 'object' && button.primary != null;
92 return (
93 <Button
94 kind={isPrimary ? 'primary' : undefined}
95 onClick={() => {
96 modal.deferred.resolve(button);
97 setModal({...modal, visible: false});
98 }}
99 ref={isPrimary ? primaryButtonRef : undefined}
100 key={index}>
101 {label}
102 </Button>
103 );
104 })}
105 </div>
106 </>
107 );
108 } else if (modal.config.type === 'custom') {
109 content = modal.config.component({
110 returnResultAndDismiss: data => {
111 modal.deferred.resolve(data);
112 setModal({...modal, visible: false});
113 },
114 });
115 }
116
117 return (
118 <Modal
119 height={modal.config.height}
120 width={modal.config.width}
121 maxHeight={modal.config.maxHeight}
122 maxWidth={modal.config.maxWidth}
123 className="use-modal"
124 aria-labelledby="use-modal-title"
125 aria-describedby="use-modal-message"
126 dataTestId={modal.config.dataTestId}
127 dismiss={dismiss}>
128 {modal.config.title != null && (
129 <div id="use-modal-title">
130 {modal.config.icon != null ? <Icon icon={modal.config.icon} size="M" /> : null}
131 {typeof modal.config.title === 'string' ? (
132 <span>{modal.config.title}</span>
133 ) : (
134 modal.config.title
135 )}
136 </div>
137 )}
138 {content}
139 </Modal>
140 );
141}
142
143/**
144 * Hook that provides a callback to show a modal with customizable behavior.
145 * Modal has a dismiss button & dismisses on Escape keypress, thus you must always be able to handle
146 * returning `undefined`.
147 *
148 * For now, we assume all uses of useOptionModal are triggered directly from a user action.
149 * If that's not the case, it would be possible to have multiple modals overlap.
150 **/
151export function useModal(): <T>(config: ModalConfig<T>) => Promise<T | undefined> {
152 const setModal = useSetAtom(modalState);
153
154 return useCallback(
155 <T,>(config: ModalConfig<T>) => {
156 const deferred = defer<T | undefined>();
157 // The API we provide is typed with T, but our recoil state only knows `unknown`, so we have to cast.
158 // This is safe because only one modal is visible at a time, so we know the data type we created it with is what we'll get back.
159 setModal({
160 config: config as ModalConfig<unknown>,
161 visible: true,
162 deferred: deferred as Deferred<unknown | undefined>,
163 });
164
165 return deferred.promise as Promise<T>;
166 },
167 [setModal],
168 );
169}
170
171export function showModal<T>(config: ModalConfig<T>): Promise<T | undefined> {
172 const deferred = defer<T | undefined>();
173 writeAtom(modalState, {
174 config: config as ModalConfig<unknown>,
175 visible: true,
176 deferred: deferred as Deferred<unknown | undefined>,
177 });
178
179 return deferred.promise as Promise<T>;
180}
181