gristlabs_grist-core/app/client/ui2018/modals.ts
Jarosław Sadziński 6460c22a89 (core) Changing shortcuts for adding and removing rows
Summary:
New shortcuts for removing and adding rows.
For adding a row we now have Mod+(Shift)+Enter
For removing rows we now have Mod+Delete/Mod+Backspace

Before removing rows, the user is prompted to confirm, this prompt
can be dismissed and this setting can be remembered. User needs
to confirm only when using shortcut.

Old shortcuts are still active and shows information about this change.
This information is shown only once, after this shortcuts have default
behavior (zooming).
New users don't see this explanation.

Test Plan: Updated

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3655
2022-10-21 18:45:25 +02:00

602 lines
19 KiB
TypeScript

import {FocusLayer} from 'app/client/lib/FocusLayer';
import {reportError} from 'app/client/models/errors';
import {cssInput} from 'app/client/ui/cssInput';
import {prepareForTransition, TransitionWatcher} from 'app/client/ui/transitions';
import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons';
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {waitGrainObs} from 'app/common/gutil';
import {IOpenController, IPopupDomCreator, IPopupOptions, PopupControl, popupOpen} from 'popweasel';
import {Computed, Disposable, dom, DomContents, DomElementArg, input, keyframes,
MultiHolder, Observable, styled} from 'grainjs';
// IModalControl is passed into the function creating the body of the modal.
export interface IModalControl {
// Observable for whether there is work in progress that's delaying the closing of the modal. It
// is useful for disabling a Save or Close button.
workInProgress: Computed<boolean>;
// Focus the modal dialog.
focus(): void;
// Request to close, without waiting. It's possible for closing to get prevented.
close(): void;
// Returns true if closed, false if closing was prevented.
closeAndWait(): Promise<boolean>;
// Prevents closing, if close has been called and is pending. No-op otherwise.
preventClose(): void;
// Wraps the passed-in function, so that closing is delayed while the function is running. If
// {close: true} is passed in, then requests closing of the modal when the function is done.
//
// With catchErrors set, errors are caught and reported, and prevent the dialog from closing.
// Otherwise, only StayOpen exception prevents closing; other errors will be propagated.
doWork<Args extends any[]>(
func: (...args: Args) => Promise<unknown>,
options?: {close?: boolean, catchErrors?: boolean},
): (...args: Args) => Promise<void>;
}
export class ModalControl extends Disposable implements IModalControl {
private _inProgress = Observable.create<number>(this, 0);
private _workInProgress = Computed.create(this, this._inProgress, (use, n) => (n > 0));
private _closePromise: Promise<boolean>|undefined;
private _shouldClose = false;
constructor(
private _doClose: () => void,
private _doFocus?: () => void,
) {
super();
}
public focus() {
this._doFocus?.();
}
public close(): void {
this.closeAndWait().catch(() => {});
}
public async closeAndWait(): Promise<boolean> {
return this._closePromise || (this._closePromise = this._doCloseAndWait());
}
public preventClose(): void {
this._shouldClose = false;
}
public get workInProgress() {
return this._workInProgress;
}
public doWork<Args extends any[]>(
func: (...args: Args) => Promise<unknown>,
options: {close?: boolean, catchErrors?: boolean} = {},
): (...args: Args) => Promise<void> {
return async (...args) => {
this._inProgress.set(this._inProgress.get() + 1);
const closePromise = options.close ? this.closeAndWait() : null;
try {
await func(...args);
} catch (err) {
if (err instanceof StayOpen) {
this.preventClose();
} else if (options.catchErrors) {
reportError(err);
this.preventClose();
} else {
throw err;
}
} finally {
this._inProgress.set(this._inProgress.get() - 1);
if (closePromise) {
await closePromise;
}
}
};
}
private async _doCloseAndWait(): Promise<boolean> {
this._shouldClose = true;
try {
// Since some modals expect an immediate close; avoid an await when no work is pending.
if (this.workInProgress.get()) {
await waitGrainObs(this.workInProgress, wip => !wip);
}
if (this._shouldClose) { this._doClose(); }
return this._shouldClose;
} finally {
this._closePromise = undefined;
}
}
}
/**
* The modal variant.
*
* Fade-in modals open with a fade-in background animation, and close immediately.
*
* Collapsing modals open with a expanding animation from a referenced DOM element, and
* close with a collapsing animation into the referenced element.
*/
export type IModalVariant = 'fade-in' | 'collapsing';
export interface IModalOptions {
// The modal variant. Defaults to "fade-in".
variant?: IModalVariant;
// Required for "collapsing" variant modals. This is the anchor element for animations.
refElement?: HTMLElement;
// If set, escape key does not close the dialog.
noEscapeKey?: boolean;
// If set, clicking into background does not close dialog.
noClickAway?: boolean;
// If given, call and wait for this before closing the dialog. If it returns false, don't close.
// Error also prevents closing, and is reported as an unexpected error.
beforeClose?: () => Promise<boolean>;
}
// 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 {
const {noEscapeKey, noClickAway, refElement = document.body, variant = 'fade-in'} = options;
function doClose() {
if (!modalDom.isConnected) { return; }
variant === 'collapsing' ? collapseAndCloseModal() : closeModal();
}
function closeModal() {
document.body.removeChild(modalDom);
// Ensure we run the disposers for the DOM contained in the modal.
dom.domDispose(modalDom);
}
function collapseAndCloseModal() {
const watcher = new TransitionWatcher(dialogDom);
watcher.onDispose(() => closeModal());
modalDom.classList.add(cssModalBacker.className + '-collapsing');
collapseModal();
}
function expandModal() {
prepareForTransition(dialogDom, () => collapseModal());
Object.assign(dialogDom.style, {
transform: '',
opacity: '',
visibility: 'visible',
});
}
function collapseModal() {
const rect = dialogDom.getBoundingClientRect();
const collapsedRect = refElement.getBoundingClientRect();
const originX = (collapsedRect.left + collapsedRect.width / 2) - rect.left;
const originY = (collapsedRect.top + collapsedRect.height / 2) - rect.top;
Object.assign(dialogDom.style, {
transform: `scale(${collapsedRect.width / rect.width}, ${collapsedRect.height / rect.height})`,
transformOrigin: `${originX}px ${originY}px`,
opacity: '0',
});
}
let close = doClose;
let dialogDom: HTMLElement;
const modalDom = cssModalBacker(
dom.create((owner) => {
const focus = () => dialogDom.focus();
const ctl = ModalControl.create(owner, doClose, focus);
close = () => ctl.close();
dialogDom = cssModalDialog(
createFn(ctl, owner),
cssModalDialog.cls('-collapsing', variant === 'collapsing'),
dom.on('click', (ev) => ev.stopPropagation()),
noEscapeKey ? null : dom.onKeyDown({ Escape: close }),
testId('modal-dialog'),
);
FocusLayer.create(owner, {
defaultFocusElem: dialogDom,
allowFocus: (elem) => (elem !== document.body),
// 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.
pauseMousetrap: true
});
return dialogDom;
}),
noClickAway ? null : dom.on('click', () => close()),
);
document.body.appendChild(modalDom);
if (variant === 'collapsing') { expandModal(); }
}
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<boolean>; // Optional observable for when to disable Save button.
saveFunc: () => Promise<unknown>; // 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 isSaveDisabled = Computed.create(owner, (use) =>
use(ctl.workInProgress) || (options.saveDisabled ? use(options.saveDisabled) : false));
const save = ctl.doWork(options.saveFunc, {close: true, catchErrors: true});
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<void>,
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,
}));
}
/**
* Creates a simple prompt modal (replacement for the native one).
* Closed via clicking anywhere outside the modal or Cancel button.
*
* Example usage:
* promptModal(
* "Enter your name",
* (name: string) => alert(`Hello ${name}`),
* "Ok" // Confirm button name,
* "John doe", // Initial text (can be empty or undefined)
* "Enter your name", // input placeholder
* () => console.log('User cancelled') // Called when user cancels, or clicks outside.
* )
*
* @param title: Prompt text.
* @param onConfirm: Handler for Confirm button.
* @param btnText: Text of the confirm button.
* @param initial: Initial value in the input element.
* @param placeholder: Placeholder for the input element.
* @param onCancel: Optional cancel handler.
*/
export function promptModal(
title: string,
onConfirm: (text: string) => Promise<unknown>,
btnText: string,
initial?: string,
placeholder?: string,
onCancel?: () => void
): void {
saveModal((ctl, owner): ISaveModalOptions => {
let confirmed = false;
const text = Observable.create(owner, initial ?? '');
const txtInput = input(text, { onInput : true }, { placeholder }, cssInput.cls(''), testId('modal-prompt'));
const options: ISaveModalOptions = {
title,
body: txtInput,
saveLabel: btnText,
saveFunc: () => {
// Mark that confirm was invoked.
confirmed = true;
return onConfirm(text.get() || '');
},
width: 'normal'
};
owner.onDispose(() => {
if (confirmed) { return; }
onCancel?.();
});
setTimeout(() => txtInput.focus(), 10);
return options;
});
}
/**
* Wraps prompt modal in a promise that is resolved either when user confirms or cancels.
* When user cancels the returned value is always undefined.
*
* Example usage:
* async handler() {
* const name = await invokePrompt("Please enter your name");
* if (name !== undefined) alert(`Hello ${name}`);
* }
*
* @param title: Prompt text.
* @param btnText: Text of the confirm button, default is "Ok".
* @param initial: Initial value in the input element.
* @param placeholder: Placeholder for the input element.
*/
export function invokePrompt(
title: string,
btnText?: string,
initial?: string,
placeholder?: string
): Promise<string|undefined> {
let onResolve: (text: string|undefined) => any;
const prom = new Promise<string|undefined>((resolve) => {
onResolve = resolve;
});
promptModal(title, onResolve!, btnText ?? 'Ok', initial, placeholder, () => {
if (onResolve) {
onResolve(undefined);
}
});
return prom;
}
/**
* Builds a simple spinner modal. The modal gets removed when `promise` resolves.
*/
export async function spinnerModal<T>(
title: string,
promise: Promise<T>): Promise<T> {
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);
}
/**
* Shows a little modal as a tooltip.
*
* Example:
* dom.on('click', (_, element) => modalTooltip(element, (ctl) => {
* return dom('div', 'Hello world', dom.on('click', () => ctl.close()));
* }))
*/
export function modalTooltip(
reference: Element,
domCreator: IPopupDomCreator,
options: IPopupOptions = {}
): PopupControl {
return popupOpen(reference, (ctl: IOpenController) => {
const element = cssModalTooltip(
domCreator(ctl)
);
return element;
}, options);
}
/* CSS styled components */
const cssModalTooltip = styled('div', `
padding: 16px;
background: ${theme.modalBg};
border-radius: 3px;
box-shadow: 0 2px 18px 0 ${theme.modalInnerShadow}, 0 0 1px 0 ${theme.modalOuterShadow};
outline: none;
& > div {
outline: none;
}
`);
// 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 cssModalDialog = styled('div', `
background-color: ${theme.modalBg};
min-width: 428px;
color: ${theme.darkText};
margin: auto;
border-radius: 3px;
box-shadow: 0 2px 18px 0 ${theme.modalInnerShadow}, 0 0 1px 0 ${theme.modalOuterShadow};
padding: 40px 64px;
outline: none;
&-normal {
max-width: 480px;
}
&-fixed-wide {
width: 600px;
}
&-collapsing {
transition-property: opacity, transform;
transition-duration: 0.4s;
transition-timing-function: ease-in-out;
}
@media ${mediaSmall} {
& {
width: unset;
min-width: unset;
padding: 24px 16px;
}
}
`);
export const cssModalTitle = styled('div', `
font-size: ${vars.xxxlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
color: ${theme.text};
margin: 0 0 16px 0;
line-height: 32px;
overflow-wrap: break-word;
`);
export const cssModalBody = styled('div', `
color: ${theme.text};
margin: 16px 0;
`);
export const cssModalButtons = styled('div', `
margin: 40px 0 0 0;
& > button,
& > .${cssButton.className} {
margin: 0 8px 0 0;
}
`);
const cssFadeIn = keyframes(`
from {background-color: transparent}
`);
const cssFadeOut = keyframes(`
from {background-color: ${theme.modalBackdrop}}
`);
const cssModalBacker = styled('div', `
position: fixed;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
top: 0;
left: 0;
padding: 16px;
z-index: 999;
background-color: ${theme.modalBackdrop};
overflow-y: auto;
animation-name: ${cssFadeIn};
animation-duration: 0.4s;
&-collapsing {
animation-name: ${cssFadeOut};
background-color: transparent;
}
`);
const cssSpinner = styled('div', `
display: flex;
align-items: center;
height: 80px;
margin: auto;
`);
const cssModalSpinner = styled('div', `
display: flex;
flex-direction: column;
`);