gristlabs_grist-core/app/client/components/modals.ts

496 lines
13 KiB
TypeScript
Raw Permalink Normal View History

import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {reportSuccess} from 'app/client/models/errors';
import {basicButton, bigPrimaryButton, primaryButton} from 'app/client/ui2018/buttons';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {mediaXSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssModalTooltip, modalTooltip} from 'app/client/ui2018/modals';
import {dom, DomContents, keyframes, observable, styled, svg} from 'grainjs';
import {IPopupOptions} from 'popweasel';
import {makeT} from 'app/client/lib/localization';
import merge = require('lodash/merge');
const t = makeT('modals');
/**
* This is a file for all custom and pre-configured popups, modals, toasts and tooltips, used
* in more then one component.
*/
/**
* Tooltip or popup to confirm row deletion.
*/
export function buildConfirmDelete(
refElement: Element,
onSave: (remember: boolean) => void,
single = true,
) {
const remember = observable(false);
const tooltip = modalTooltip(refElement, (ctl) =>
cssContainer(
dom.autoDispose(remember),
testId('confirm-deleteRows'),
testId('confirm-popup'),
elem => { FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}); },
dom.onKeyDown({
Escape: () => ctl.close(),
Enter: () => { onSave(remember.get()); ctl.close(); },
}),
dom('div', single ?
t(`Are you sure you want to delete this record?`)
: t(`Are you sure you want to delete these records?`),
dom.style('margin-bottom', '10px'),
),
dom('div',
labeledSquareCheckbox(remember, t("Don't ask again."), testId('confirm-remember')),
dom.style('margin-bottom', '10px'),
),
cssButtons(
primaryButton(t('Delete'), testId('confirm-save'), dom.on('click', () => {
onSave(remember.get());
ctl.close();
})),
basicButton(t('Cancel'), testId('confirm-cancel'), dom.on('click', () => ctl.close()))
)
), {}
);
// Attach this tooltip to a cell so that it is automatically closed when the cell is disposed.
// or scrolled out of view (and then disposed).
dom.onDisposeElem(refElement, () => {
if (!tooltip.isDisposed()) {
tooltip.close();
}
});
return tooltip;
}
export function showDeprecatedWarning(
refElement: Element,
content: DomContents,
onClose: (checked: boolean) => void,
) {
const remember = observable(false);
const tooltip = modalTooltip(refElement, (ctl) =>
cssWideContainer(
testId('popup-warning-deprecated'),
elem => { FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}); },
dom.onKeyDown({
Escape: () => { ctl.close(); onClose(remember.get()); },
Enter: () => { ctl.close(); onClose(remember.get()); },
}),
content,
cssButtons(
dom.style('margin-top', '12px'),
dom.style('justify-content', 'space-between'),
dom.style('align-items', 'center'),
dom('div',
labeledSquareCheckbox(remember, t("Don't show again."), testId('confirm-remember')),
),
basicButton(t('Dismiss'), testId('confirm-save'),
dom.on('click', () => { ctl.close(); onClose(remember.get()); })
)
),
)
);
// Attach this warning to a cell so that it is automatically closed when the cell is disposed.
// or scrolled out of view (and then disposed).
dom.onDisposeElem(refElement, () => {
if (!tooltip.isDisposed()) {
tooltip.close();
}
});
return tooltip;
}
/**
* Shows notification with a single button 'Undo' delete.
*/
export function reportUndo(
doc: GristDoc,
messageLabel: string,
buttonLabel = t('Undo to restore')
) {
// First create a notification with a button to undo the delete.
let notification = reportSuccess(messageLabel, {
key: 'undo',
actions: [{
label: buttonLabel,
action: () => {
// When user clicks on the button, undo the last action.
commands.allCommands.undo.run();
// And remove this notification.
close();
},
}]
});
// When we received some actions from the server, cancel this popup,
// as the undo might do something else.
doc.on('onDocUserAction', close);
notification?.onDispose(() => doc.off('onDocUserAction', close));
function close() {
if (notification && !notification?.isDisposed()) {
notification.dispose();
notification = undefined;
}
}
}
export interface ShowTipPopupOptions {
onClose: (dontShowTips: boolean) => void;
/** Defaults to false. */
hideArrow?: boolean;
/** Defaults to false. */
hideDontShowTips?: boolean;
popupOptions?: IPopupOptions;
}
export function showTipPopup(
refElement: Element,
title: string,
content: DomContents,
options: ShowTipPopupOptions
) {
const {onClose, hideArrow = false, hideDontShowTips = false, popupOptions} = options;
const arrow = hideArrow ? null : buildArrow();
const dontShowTips = observable(false);
const tooltip = modalTooltip(refElement,
(ctl) => [
cssBehavioralPromptModal.cls(''),
arrow,
cssBehavioralPromptContainer(
dom.autoDispose(dontShowTips),
testId('behavioral-prompt'),
elem => { FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}); },
dom.onKeyDown({
Escape: () => ctl.close(),
Enter: () => { onClose(dontShowTips.get()); ctl.close(); },
}),
cssBehavioralPromptHeader(
cssHeaderIconAndText(
icon('Idea'),
cssHeaderText(t('TIP')),
),
),
cssBehavioralPromptBody(
cssBehavioralPromptTitle(title, testId('behavioral-prompt-title')),
content,
cssButtons(
dom.style('margin-top', '12px'),
dom.style('justify-content', 'space-between'),
dom.style('align-items', 'center'),
dom('div',
cssSkipTipsCheckbox(dontShowTips,
cssSkipTipsCheckboxLabel(t("Don't show tips")),
testId('behavioral-prompt-dont-show-tips')
),
dom.style('visibility', hideDontShowTips ? 'hidden' : ''),
),
cssDismissPromptButton(t('Got it'), testId('behavioral-prompt-dismiss'),
dom.on('click', () => { onClose(dontShowTips.get()); ctl.close(); })
),
),
),
),
],
merge({}, defaultPopupOptions, popupOptions),
);
dom.onDisposeElem(refElement, () => {
if (!tooltip.isDisposed()) {
tooltip.close();
}
});
return tooltip;
}
export interface ShowNewsPopupOptions {
popupOptions?: IPopupOptions;
}
export function showNewsPopup(
refElement: Element,
title: string,
content: DomContents,
options: ShowNewsPopupOptions = {}
) {
const {popupOptions} = options;
const popup = modalTooltip(refElement,
(ctl) => [
cssNewsPopupModal.cls(''),
cssNewsPopupContainer(
testId('behavioral-prompt'),
elem => { FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}); },
dom.onKeyDown({
Escape: () => { ctl.close(); },
Enter: () => { ctl.close(); },
}),
cssNewsPopupCloseButton(
icon('CrossBig'),
dom.on('click', () => ctl.close()),
testId('behavioral-prompt-dismiss'),
),
cssNewsPopupBody(
cssNewsPopupTitle(title, testId('behavioral-prompt-title')),
content,
),
),
],
merge({}, defaultPopupOptions, popupOptions),
);
dom.onDisposeElem(refElement, () => {
if (!popup.isDisposed()) {
popup.close();
}
});
return popup;
}
const defaultPopupOptions = {
modifiers: {
offset: {
offset: '0,12',
},
preventOverflow: {
boundariesElement: 'window',
padding: 32,
},
computeStyle: {
// GPU acceleration makes text look blurry.
gpuAcceleration: false,
},
}
};
function buildArrow() {
return cssArrowContainer(
svg('svg',
{style: 'width: 13px; height: 18px;'},
svg('path', {'d': 'M 0 0 h 13 v 18 Z'}),
),
);
}
function sideSelectorChunk(side: 'top'|'bottom'|'left'|'right') {
return `.${cssModalTooltip.className}[x-placement^=${side}]`;
}
function fadeInFromSide(side: 'top'|'bottom'|'left'|'right') {
let startPosition: string;
switch(side) {
case 'top': {
startPosition = '0px -25px';
break;
}
case 'bottom': {
startPosition = '0px 25px';
break;
}
case'left': {
startPosition = '-25px 0px';
break;
}
case 'right': {
startPosition = '25px 0px';
break;
}
}
return keyframes(`
from {translate: ${startPosition}; opacity: 0;}
to {translate: 0px 0px; opacity: 1;}
`);
}
const HEADER_HEIGHT_PX = 30;
const cssArrowContainer = styled('div', `
position: absolute;
& path {
stroke: ${theme.popupBg};
stroke-width: 2px;
fill: ${theme.popupBg};
}
${sideSelectorChunk('bottom')} > & path {
stroke: ${theme.controlPrimaryBg};
fill: ${theme.controlPrimaryBg};
}
${sideSelectorChunk('top')} > & {
bottom: -17px;
margin: 0px 16px;
}
${sideSelectorChunk('bottom')} > & {
top: -14px;
margin: 0px 16px;
}
${sideSelectorChunk('right')} > & {
left: -12px;
margin: ${HEADER_HEIGHT_PX}px 0px ${HEADER_HEIGHT_PX}px 0px;
}
${sideSelectorChunk('left')} > & {
right: -12px;
margin: ${HEADER_HEIGHT_PX}px 0px ${HEADER_HEIGHT_PX}px 0px;
}
${sideSelectorChunk('top')} svg {
transform: rotate(-90deg);
}
${sideSelectorChunk('bottom')} svg {
transform: rotate(90deg);
}
${sideSelectorChunk('left')} svg {
transform: scalex(-1);
}
`);
const cssTheme = styled('div', `
color: ${theme.text};
`);
const cssButtons = styled('div', `
display: flex;
gap: 6px;
`);
const cssContainer = styled(cssTheme, `
max-width: 270px;
`);
const cssWideContainer = styled(cssTheme, `
max-width: 340px;
`);
const cssFadeInFromTop = fadeInFromSide('top');
const cssFadeInFromBottom = fadeInFromSide('bottom');
const cssFadeInFromLeft = fadeInFromSide('left');
const cssFadeInFromRight = fadeInFromSide('right');
const cssBehavioralPromptModal = styled('div', `
margin: 0px;
padding: 0px;
width: 400px;
border-radius: 4px;
animation-duration: 0.4s;
position: absolute;
&[x-placement^=top] {
animation-name: ${cssFadeInFromTop};
}
&[x-placement^=bottom] {
animation-name: ${cssFadeInFromBottom};
}
&[x-placement^=left] {
animation-name: ${cssFadeInFromLeft};
}
&[x-placement^=right] {
animation-name: ${cssFadeInFromRight};
}
@media ${mediaXSmall} {
& {
/* Allocate 32px of space for the left and right margins. */
width: calc(100% - 64px);
}
}
`);
const cssNewsPopupModal = cssBehavioralPromptModal;
const cssBehavioralPromptContainer = styled(cssTheme, `
line-height: 18px;
`);
const cssNewsPopupContainer = styled('div', `
background: linear-gradient(to right, #29a3a3, #16a772);
color: white;
border-radius: 4px;
`);
const cssBehavioralPromptHeader = styled('div', `
display: flex;
justify-content: center;
background-color: ${theme.controlPrimaryBg};
color: ${theme.controlPrimaryFg};
--icon-color: ${theme.controlPrimaryFg};
border-radius: 4px 4px 0px 0px;
line-height: ${HEADER_HEIGHT_PX}px;
`);
const cssBehavioralPromptBody = styled('div', `
padding: 16px;
`);
const cssNewsPopupBody = styled('div', `
font-size: 14px;
line-height: 23px;
padding: 16px;
`);
const cssHeaderIconAndText = styled('div', `
display: flex;
align-items: center;
column-gap: 8px;
`);
const cssHeaderText = styled('div', `
font-weight: 600;
`);
const cssDismissPromptButton = styled(bigPrimaryButton, `
margin-right: 8px;
`);
const cssBehavioralPromptTitle = styled('div', `
font-size: ${vars.xxxlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
color: ${theme.text};
margin: 0 0 16px 0;
line-height: 32px;
`);
const cssNewsPopupTitle = styled('div', `
font-size: ${vars.xxxlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
margin: 0 0 16px 0;
line-height: 32px;
`);
const cssNewsPopupCloseButton = styled('div', `
position: absolute;
top: 8px;
right: 8px;
padding: 4px;
border-radius: 4px;
cursor: pointer;
--icon-color: white;
&:hover {
background-color: ${theme.hover};
}
`);
const cssSkipTipsCheckbox = styled(labeledSquareCheckbox, `
line-height: normal;
`);
const cssSkipTipsCheckboxLabel = styled('span', `
color: ${theme.lightText};
`);