addons/isl/src/useModal.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {Deferred} from 'shared/utils';
b69ab319
b69ab3110import {Button} from 'isl-components/Button';
b69ab3111import {Icon} from 'isl-components/Icon';
b69ab3112import {atom, useAtom, useSetAtom} from 'jotai';
b69ab3113import React, {useCallback, useEffect, useRef} from 'react';
b69ab3114import {defer} from 'shared/utils';
b69ab3115import {useCommand} from './ISLShortcuts';
b69ab3116import {Modal} from './Modal';
b69ab3117import {writeAtom} from './jotaiUtils';
b69ab3118
b69ab3119import './useModal.css';
b69ab3120
b69ab3121type ButtonConfig = {label: string | React.ReactNode; primary?: boolean};
b69ab3122type ModalConfigBase = {
b69ab3123 /** Optional codicon to show next to the title */
b69ab3124 icon?: string;
b69ab3125 title?: React.ReactNode;
b69ab3126 width?: string | number;
b69ab3127 height?: string | number;
b69ab3128 maxWidth?: string | number;
b69ab3129 maxHeight?: string | number;
b69ab3130 dataTestId?: string;
b69ab3131};
b69ab3132type ModalConfig<T> = ModalConfigBase &
b69ab3133 (
b69ab3134 | {
b69ab3135 // hack: using 'confirm' mode requires T to be string.
b69ab3136 // The type inference goes wrong if we try to add this constraint directly to the `buttons` field.
b69ab3137 // By adding the constraint here, we get type checking that T is string in order to use this API.
b69ab3138 type: T extends string ? 'confirm' : T extends ButtonConfig ? 'confirm' : never;
b69ab3139 message: React.ReactNode;
b69ab3140 buttons: ReadonlyArray<T>;
b69ab3141 }
b69ab3142 | {
b69ab3143 type: 'custom';
b69ab3144 component: (props: {returnResultAndDismiss: (data: T) => void}) => React.ReactNode;
b69ab3145 }
b69ab3146 );
b69ab3147type ModalState<T> = {
b69ab3148 config: ModalConfig<T>;
b69ab3149 visible: boolean;
b69ab3150 deferred: Deferred<T | undefined>;
b69ab3151};
b69ab3152
b69ab3153const modalState = atom<ModalState<unknown | string> | null>(null);
b69ab3154
b69ab3155/** Wrapper around <Modal>, generated by `useModal()` hooks. */
b69ab3156export function ModalContainer() {
b69ab3157 const [modal, setModal] = useAtom(modalState);
b69ab3158
b69ab3159 // we expect at most one button is "primary"
b69ab3160 const primaryButtonRef = useRef(null);
b69ab3161
b69ab3162 const dismiss = () => {
b69ab3163 if (modal?.visible) {
b69ab3164 modal.deferred.resolve(undefined);
b69ab3165 setModal({...modal, visible: false});
b69ab3166 }
b69ab3167 };
b69ab3168
b69ab3169 useCommand('Escape', dismiss);
b69ab3170
b69ab3171 // focus primary button on mount
b69ab3172 useEffect(() => {
b69ab3173 if (modal?.visible && primaryButtonRef.current != null) {
b69ab3174 (primaryButtonRef.current as HTMLButtonElement).focus();
b69ab3175 }
b69ab3176 }, [primaryButtonRef, modal?.visible]);
b69ab3177
b69ab3178 if (modal?.visible !== true) {
b69ab3179 return null;
b69ab3180 }
b69ab3181
b69ab3182 let content;
b69ab3183 if ((modal.config as ModalConfig<string>).type === 'confirm') {
b69ab3184 const config = modal.config as ModalConfig<string> & {type: 'confirm'};
b69ab3185 content = (
b69ab3186 <>
b69ab3187 <div id="use-modal-message">{config.message}</div>
b69ab3188 <div className="use-modal-buttons">
b69ab3189 {config.buttons.map((button: string | ButtonConfig, index: number) => {
b69ab3190 const label = typeof button === 'object' ? button.label : button;
b69ab3191 const isPrimary = typeof button === 'object' && button.primary != null;
b69ab3192 return (
b69ab3193 <Button
b69ab3194 kind={isPrimary ? 'primary' : undefined}
b69ab3195 onClick={() => {
b69ab3196 modal.deferred.resolve(button);
b69ab3197 setModal({...modal, visible: false});
b69ab3198 }}
b69ab3199 ref={isPrimary ? primaryButtonRef : undefined}
b69ab31100 key={index}>
b69ab31101 {label}
b69ab31102 </Button>
b69ab31103 );
b69ab31104 })}
b69ab31105 </div>
b69ab31106 </>
b69ab31107 );
b69ab31108 } else if (modal.config.type === 'custom') {
b69ab31109 content = modal.config.component({
b69ab31110 returnResultAndDismiss: data => {
b69ab31111 modal.deferred.resolve(data);
b69ab31112 setModal({...modal, visible: false});
b69ab31113 },
b69ab31114 });
b69ab31115 }
b69ab31116
b69ab31117 return (
b69ab31118 <Modal
b69ab31119 height={modal.config.height}
b69ab31120 width={modal.config.width}
b69ab31121 maxHeight={modal.config.maxHeight}
b69ab31122 maxWidth={modal.config.maxWidth}
b69ab31123 className="use-modal"
b69ab31124 aria-labelledby="use-modal-title"
b69ab31125 aria-describedby="use-modal-message"
b69ab31126 dataTestId={modal.config.dataTestId}
b69ab31127 dismiss={dismiss}>
b69ab31128 {modal.config.title != null && (
b69ab31129 <div id="use-modal-title">
b69ab31130 {modal.config.icon != null ? <Icon icon={modal.config.icon} size="M" /> : null}
b69ab31131 {typeof modal.config.title === 'string' ? (
b69ab31132 <span>{modal.config.title}</span>
b69ab31133 ) : (
b69ab31134 modal.config.title
b69ab31135 )}
b69ab31136 </div>
b69ab31137 )}
b69ab31138 {content}
b69ab31139 </Modal>
b69ab31140 );
b69ab31141}
b69ab31142
b69ab31143/**
b69ab31144 * Hook that provides a callback to show a modal with customizable behavior.
b69ab31145 * Modal has a dismiss button & dismisses on Escape keypress, thus you must always be able to handle
b69ab31146 * returning `undefined`.
b69ab31147 *
b69ab31148 * For now, we assume all uses of useOptionModal are triggered directly from a user action.
b69ab31149 * If that's not the case, it would be possible to have multiple modals overlap.
b69ab31150 **/
b69ab31151export function useModal(): <T>(config: ModalConfig<T>) => Promise<T | undefined> {
b69ab31152 const setModal = useSetAtom(modalState);
b69ab31153
b69ab31154 return useCallback(
b69ab31155 <T,>(config: ModalConfig<T>) => {
b69ab31156 const deferred = defer<T | undefined>();
b69ab31157 // The API we provide is typed with T, but our recoil state only knows `unknown`, so we have to cast.
b69ab31158 // 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.
b69ab31159 setModal({
b69ab31160 config: config as ModalConfig<unknown>,
b69ab31161 visible: true,
b69ab31162 deferred: deferred as Deferred<unknown | undefined>,
b69ab31163 });
b69ab31164
b69ab31165 return deferred.promise as Promise<T>;
b69ab31166 },
b69ab31167 [setModal],
b69ab31168 );
b69ab31169}
b69ab31170
b69ab31171export function showModal<T>(config: ModalConfig<T>): Promise<T | undefined> {
b69ab31172 const deferred = defer<T | undefined>();
b69ab31173 writeAtom(modalState, {
b69ab31174 config: config as ModalConfig<unknown>,
b69ab31175 visible: true,
b69ab31176 deferred: deferred as Deferred<unknown | undefined>,
b69ab31177 });
b69ab31178
b69ab31179 return deferred.promise as Promise<T>;
b69ab31180}