import {FocusLayer} from 'app/client/lib/FocusLayer'; import * as Mousetrap from 'app/client/lib/Mousetrap'; import {reportError} from 'app/client/models/errors'; import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons'; import {colors, testId, vars} from 'app/client/ui2018/cssVars'; import {loadingSpinner} from 'app/client/ui2018/loaders'; import {Computed, dom, DomContents, DomElementArg, MultiHolder, Observable, styled} from 'grainjs'; export interface IModalControl { close(): void; focus(): void; } export interface IModalOptions { noEscapeKey?: boolean; // If set, escape key does not close the dialog noClickAway?: boolean; // If set, clicking into background does not close dialog. } // A custom error type to signal to the modal that it should stay open, but not report any error // (presumably because the error was already reported). export class StayOpen extends Error { } export type ModalWidth = 'normal' | // Normal dialog, from 428px to 480px in width. 'fixed-wide'; // Fixed 600px width. /** * A simple modal. Shows up in the middle of the screen with a tinted backdrop. * Created with the given body content and width. * * Closed via clicking anywhere outside the modal. May also be closed by * calling ctl.close(). * * The createFn callback may tie the disposal of temporary objects to its `owner` argument. * * Example usage: * modal((ctl, owner) => [ * cssModalTitle(`Pin doc`), * cssModalBody('Are you sure you want to pin doc?') * cssModalButtons( * primary('Yes', dom.on('click', () => { onClick(true); ctl.close(); })), * secondary('Cancel', dom.on('click', () => { onClick(false); ctl.close(); })) * ) * ]) */ export function modal(createFn: (ctl: IModalControl, owner: MultiHolder) => DomElementArg, options: IModalOptions = {}): void { function close() { if (modalDom.isConnected) { document.body.removeChild(modalDom); // Ensure we run the disposers for the DOM contained in the modal. dom.domDispose(modalDom); } } const modalDom = cssModalBacker( dom.create((owner) => { // Pause mousetrap keyboard shortcuts while the modal is shown. Without this, arrow keys // will navigate in a grid underneath the modal, and Enter may open a cell there. Mousetrap.setPaused(true); owner.onDispose(() => Mousetrap.setPaused(false)); const focus = () => dialog.focus(); const dialog = cssModalDialog( createFn({ close, focus }, owner), dom.on('click', (ev) => ev.stopPropagation()), options.noEscapeKey ? null : dom.onKeyDown({ Escape: close }), testId('modal-dialog') ); FocusLayer.create(owner, { defaultFocusElem: dialog, allowFocus: (elem) => (elem !== document.body), }); return dialog; }), options.noClickAway ? null : dom.on('click', close), ); document.body.appendChild(modalDom); } export interface ISaveModalOptions { title: DomElementArg; // Normally just a string. body: DomElementArg; // Content of the dialog. saveLabel?: DomElementArg; // Normally just a string; defaults to "Save". saveDisabled?: Observable; // Optional observable for when to disable Save button. saveFunc: () => Promise; // Called on Save; dialog closes when promise is fulfilled. hideCancel?: boolean; // If set, hide the Cancel button width?: ModalWidth; // Set a width style for the dialog. modalArgs?: DomElementArg; // Extra args to apply to the outer cssModalDialog element. extraButtons?: DomContents; // More buttons! } /** * Creates a modal dialog with a title, body, and Save/Cancel buttons. The provided createFunc() * is called immediately to get the dialog's contents and options (see ISaveModalOptions for * details). For example: * * saveModal((ctl, owner) => { * const myObs = Computed.create(owner, ...); * return { * title: 'My Dialog', * body: dom('div', 'Hello', dom.text(myObs)), * saveDisabled: Computed.create(owner, (use) => !use(myObs)), * saveFunc: () => server.ping(), * modalArgs: {style: 'background-color: blue'}, * }; * }); * * On Save, the dialog calls saveFunc(), disables the Save button, and stays open until saveFunc() * is resolved. It then closes on success, or reports the error and stays open on rejection. To * stay open without reporting an error (if one is already reported), throw StayOpen exception. * * The dialog interprets Enter/Escape keys as if the Save/Cancel buttons were clicked. * * Note that it's possible to close the dialog via Cancel while saveFunc() is pending. That's * probably desirable, but keep in mind that the dialog may be disposed before saveFunc() returns. * * Error handling examples: * 1. saveFunc: doSomething * (Most common) If doSomething fails, the error is reported and the dialog stays open. * 2. saveFunc: () => doSomething().catch(reportError) * If doSomething fails, the error is reported but the dialog closes anyway. * 3. saveFunc: () => doSomething().catch((e) => { alert("BOOM"); throw new StayOpen(); }) * If doSomething fails, an alert is shown, and the dialog stays open. */ export function saveModal(createFunc: (ctl: IModalControl, owner: MultiHolder) => ISaveModalOptions) { return modal((ctl, owner) => { const options = createFunc(ctl, owner); const isSaving = Observable.create(owner, false); const isSaveDisabled = Computed.create(owner, (use) => use(isSaving) || (options.saveDisabled ? use(options.saveDisabled) : false)); // We mark isSaving() observable to disable the save button while saveFunc() is pending. // (I decided against a waitWithObsSet() helper for this, since it's too specific to this case // when saveFunc() is prevented from being called multiple times in parallel.) async function save() { isSaving.set(true); try { await options.saveFunc(); ctl.close(); // Close on success. } catch (err) { // Report errors. If saveFunc() reports its own error and wants the dialog to stay open, // it should throw StayOpen(). if (!(err instanceof StayOpen)) { reportError(err); } isSaving.set(false); } } return [ cssModalTitle(options.title, testId('modal-title')), cssModalBody(options.body), cssModalButtons( bigPrimaryButton(options.saveLabel || 'Save', dom.boolAttr('disabled', isSaveDisabled), dom.on('click', save), testId('modal-confirm'), ), options.extraButtons, options.hideCancel ? null : bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('modal-cancel'), ), ), dom.onKeyDown({ Enter: () => isSaveDisabled.get() || save() }), options.width && cssModalWidth(options.width), options.modalArgs, ]; }); } /** * Builds a simple confirm modal with 'Enter' bound to the confirm action. * * See saveModal() for error handling notes that here apply to the onConfirm callback. */ export function confirmModal( title: string, btnText: string, onConfirm: () => Promise, explanation?: Element|string, {hideCancel, extraButtons}: {hideCancel?: boolean, extraButtons?: DomContents} = {}, ): void { return saveModal((ctl, owner): ISaveModalOptions => ({ title, body: explanation || null, saveLabel: btnText, saveFunc: onConfirm, hideCancel, width: 'normal', extraButtons, })); } /** * Builds a simple spinner modal. The modal gets removed when `promise` resolves. */ export async function spinnerModal( title: string, promise: Promise): Promise { modal((ctl, owner) => { // `finally` is missing from es2016, below is a work-around. const close = () => ctl.close(); promise.then(close, close); return [ cssModalSpinner.cls(''), cssModalTitle( title, testId('modal-spinner-title'), ), cssSpinner(loadingSpinner()), testId('modal-spinner'), ]; }, { noClickAway: true, noEscapeKey: true, }); return await promise; } /** * Apply this to a modal as * modal(() => [cssModalBody(...), cssModalWidth('normal')]) * or * saveModal(() => {..., width: 'normal'}) */ export function cssModalWidth(style: ModalWidth) { return cssModalDialog.cls('-' + style); } /* CSS styled components */ const cssModalDialog = styled('div', ` background-color: white; min-width: 428px; color: black; margin: auto; border-radius: 3px; box-shadow: 0 2px 18px 0 rgba(31,37,50,0.31), 0 0 1px 0 rgba(76,86,103,0.24); padding: 40px 64px; outline: none; &-normal { max-width: 480px; } &-fixed-wide { width: 600px; } `); export const cssModalTitle = styled('div', ` font-size: ${vars.xxxlargeFontSize}; font-weight: ${vars.headerControlTextWeight}; color: ${colors.dark}; margin: 0 0 16px 0; line-height: 32px; `); export const cssModalBody = styled('div', ` margin: 16px 0; `); export const cssModalButtons = styled('div', ` margin: 40px 0 0 0; & > button, & > .${cssButton.className} { margin: 0 8px 0 0; } `); // For centering, we use 'margin: auto' on the flex item instead of 'justify-content: center' on // the flex container, to ensure the full item can be scrolled in case of overflow. // See https://stackoverflow.com/a/33455342/328565 const cssModalBacker = styled('div', ` position: fixed; display: flex; flex-direction: column; width: 100%; height: 100%; top: 0; left: 0; z-index: 999; background-color: ${colors.backdrop}; overflow-y: auto; `); const cssSpinner = styled('div', ` display: flex; align-items: center; height: 80px; margin: auto; `); const cssModalSpinner = styled('div', ` display: flex; flex-direction: column; `);