(core) Polish tutorial popups

Summary:
Includes the following changes:
 *  Adds "Click to expand" hover tooltip to all images
 *  Adds support for minimize/maximize by double clicking tutorial popup header
 *  Add New menu (and all other popups) should now persist when user moves tutorial popup
 *  Preserves scrollbar position when minimizing and maximizing tutorial popup
 *  Formula cell editor (and other elements) should now be stacked under tutorial

Test Plan: Browser and manual tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3864
This commit is contained in:
George Gevoian 2023-04-20 09:07:45 -04:00
parent f9f212d328
commit 3aac027a13
18 changed files with 199 additions and 61 deletions

View File

@ -134,7 +134,7 @@ div.dev_warning {
display: none; display: none;
width: 100%; width: 100%;
position: absolute; position: absolute;
z-index: 5000; z-index: var(--grist-browser-check-z-index);
bottom: 0; bottom: 0;
left: 0; left: 0;
padding: 4px; padding: 4px;

View File

@ -24,15 +24,15 @@ export async function duplicatePage(gristDoc: GristDoc, pageId: number) {
let inputEl: HTMLInputElement; let inputEl: HTMLInputElement;
setTimeout(() => { inputEl.focus(); inputEl.select(); }, 100); setTimeout(() => { inputEl.focus(); inputEl.select(); }, 100);
confirmModal('Duplicate page', 'Save', () => makeDuplicate(gristDoc, pageId, inputEl.value), ( confirmModal('Duplicate page', 'Save', () => makeDuplicate(gristDoc, pageId, inputEl.value), {
dom('div', [ explanation: dom('div', [
cssField( cssField(
cssLabel("Name"), cssLabel("Name"),
inputEl = cssInput({value: pageName + ' (copy)'}), inputEl = cssInput({value: pageName + ' (copy)'}),
), ),
t("Note that this does not copy data, but creates another view of the same data."), t("Note that this does not copy data, but creates another view of the same data."),
]) ]),
)); });
} }
async function makeDuplicate(gristDoc: GristDoc, pageId: number, pageName: string = '') { async function makeDuplicate(gristDoc: GristDoc, pageId: number, pageName: string = '') {

View File

@ -262,6 +262,8 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
t("Error accessing document"), t("Error accessing document"),
t("Reload"), t("Reload"),
async () => window.location.reload(true), async () => window.location.reload(true),
{
explanation: (
isDocOwner isDocOwner
? t("You can try reloading the document, or using recovery mode. " + ? t("You can try reloading the document, or using recovery mode. " +
"Recovery mode opens the document to be fully accessible to " + "Recovery mode opens the document to be fully accessible to " +
@ -269,8 +271,8 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
"formulas. [{{error}}]", {error: err.message}) "formulas. [{{error}}]", {error: err.message})
: isDenied : isDenied
? t('Sorry, access to this document has been denied. [{{error}}]', {error: err.message}) ? t('Sorry, access to this document has been denied. [{{error}}]', {error: err.message})
: t("Document owners can attempt to recover the document. [{{error}}]", {error: err.message}), : t("Document owners can attempt to recover the document. [{{error}}]", {error: err.message})
{ ),
hideCancel: true, hideCancel: true,
extraButtons: !(isDocOwner && !isDenied) ? null : bigBasicButton( extraButtons: !(isDocOwner && !isDenied) ? null : bigBasicButton(
t("Enter recovery mode"), t("Enter recovery mode"),

View File

@ -115,8 +115,12 @@ export class ApiKey extends Disposable {
confirmModal( confirmModal(
t("Remove API Key"), t("Remove"), t("Remove API Key"), t("Remove"),
() => this._onDelete(), () => this._onDelete(),
t("You're about to delete an API key. This will cause all future requests " + {
"using this API key to be rejected. Do you still want to delete?") explanation: t(
"You're about to delete an API key. This will cause all future requests " +
"using this API key to be rejected. Do you still want to delete?"
),
}
); );
} }
} }

View File

@ -19,7 +19,7 @@ body {
left: 10%; left: 10%;
height: 80%; height: 80%;
width: 80%; width: 80%;
z-index: 999; z-index: var(--grist-modal-z-index);
padding: 1rem; padding: 1rem;

View File

@ -476,7 +476,8 @@ export function makeDocOptionsMenu(home: HomeModel, doc: Document, renaming: Obs
function deleteDoc() { function deleteDoc() {
confirmModal(t("Delete {{name}}", {name: doc.name}), t("Delete"), confirmModal(t("Delete {{name}}", {name: doc.name}), t("Delete"),
() => home.deleteDoc(doc.id, false).catch(reportError), () => home.deleteDoc(doc.id, false).catch(reportError),
t("Document will be moved to Trash.")); {explanation: t("Document will be moved to Trash.")}
);
} }
async function manageUsers() { async function manageUsers() {
@ -529,7 +530,8 @@ export function makeRemovedDocOptionsMenu(home: HomeModel, doc: Document, worksp
function hardDeleteDoc() { function hardDeleteDoc() {
confirmModal(t("Permanently Delete \"{{name}}\"?", {name: doc.name}), t("Delete Forever"), confirmModal(t("Permanently Delete \"{{name}}\"?", {name: doc.name}), t("Delete Forever"),
() => home.deleteDoc(doc.id, true).catch(reportError), () => home.deleteDoc(doc.id, true).catch(reportError),
t("Document will be permanently deleted.")); {explanation: t("Document will be permanently deleted.")}
);
} }
return [ return [

View File

@ -3,9 +3,9 @@ import {urlState} from 'app/client/models/gristUrlState';
import {renderer} from 'app/client/ui/DocTutorialRenderer'; import {renderer} from 'app/client/ui/DocTutorialRenderer';
import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup'; import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {hoverTooltip} from 'app/client/ui/tooltips'; import {hoverTooltip, setHoverTooltip} from 'app/client/ui/tooltips';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {mediaXSmall, theme} from 'app/client/ui2018/cssVars'; import {mediaXSmall, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {loadingSpinner} from 'app/client/ui2018/loaders'; import {loadingSpinner} from 'app/client/ui2018/loaders';
import {confirmModal, modal} from 'app/client/ui2018/modals'; import {confirmModal, modal} from 'app/client/ui2018/modals';
@ -25,6 +25,8 @@ interface DocTutorialSlide {
const testId = makeTestId('test-doc-tutorial-'); const testId = makeTestId('test-doc-tutorial-');
const TOOLTIP_KEY = 'docTutorialTooltip';
export class DocTutorial extends FloatingPopup { export class DocTutorial extends FloatingPopup {
private _appModel = this._gristDoc.docPageModel.appModel; private _appModel = this._gristDoc.docPageModel.appModel;
private _currentDoc = this._gristDoc.docPageModel.currentDoc.get(); private _currentDoc = this._gristDoc.docPageModel.currentDoc.get();
@ -44,7 +46,7 @@ export class DocTutorial extends FloatingPopup {
}); });
constructor(private _gristDoc: GristDoc) { constructor(private _gristDoc: GristDoc) {
super(); super({stopClickPropagationOnMove: true});
} }
public async start() { public async start() {
@ -77,7 +79,7 @@ export class DocTutorial extends FloatingPopup {
this._openLightbox((ev.target as HTMLImageElement).src); this._openLightbox((ev.target as HTMLImageElement).src);
}), }),
this._restartGIFs(), this._initializeImages(),
], ],
testId('popup-body'), testId('popup-body'),
); );
@ -94,14 +96,17 @@ export class DocTutorial extends FloatingPopup {
return [ return [
cssFooterButtonsLeft( cssFooterButtonsLeft(
cssPopupFooterButton(icon('Undo'), cssPopupFooterButton(icon('Undo'),
hoverTooltip('Restart Tutorial', {key: 'docTutorialTooltip'}), hoverTooltip('Restart Tutorial', {key: TOOLTIP_KEY}),
dom.on('click', () => this._restartTutorial()), dom.on('click', () => this._restartTutorial()),
testId('popup-restart'), testId('popup-restart'),
), ),
), ),
cssProgressBar( cssProgressBar(
range(slides.length).map((i) => cssProgressBarDot( range(slides.length).map((i) => cssProgressBarDot(
{title: slides[i].slideTitle}, hoverTooltip(slides[i].slideTitle, {
closeOnClick: false,
key: TOOLTIP_KEY,
}),
cssProgressBarDot.cls('-current', i === slideIndex), cssProgressBarDot.cls('-current', i === slideIndex),
i === slideIndex ? null : dom.on('click', () => this._changeSlide(i)), i === slideIndex ? null : dom.on('click', () => this._changeSlide(i)),
testId(`popup-slide-${i + 1}`), testId(`popup-slide-${i + 1}`),
@ -251,11 +256,19 @@ export class DocTutorial extends FloatingPopup {
confirmModal( confirmModal(
'Do you want to restart the tutorial? All progress will be lost.', 'Do you want to restart the tutorial? All progress will be lost.',
'Restart', 'Restart',
doRestart doRestart,
{
modalOptions: {
backerDomArgs: [
// Stack modal above the tutorial popup.
dom.style('z-index', vars.tutorialModalZIndex.toString()),
],
},
}
); );
} }
private _restartGIFs() { private _initializeImages() {
return (element: HTMLElement) => { return (element: HTMLElement) => {
setTimeout(() => { setTimeout(() => {
const imgs = element.querySelectorAll('img'); const imgs = element.querySelectorAll('img');
@ -263,6 +276,16 @@ export class DocTutorial extends FloatingPopup {
// Re-assigning src to itself is a neat way to restart a GIF. // Re-assigning src to itself is a neat way to restart a GIF.
// eslint-disable-next-line no-self-assign // eslint-disable-next-line no-self-assign
img.src = img.src; img.src = img.src;
setHoverTooltip(img, 'Click to expand', {
key: TOOLTIP_KEY,
modifiers: {
flip: {
boundariesElement: 'scrollParent',
},
},
placement: 'bottom',
});
} }
}, 0); }, 0);
}; };
@ -281,6 +304,11 @@ export class DocTutorial extends FloatingPopup {
dom.on('click', (ev, elem) => void (ev.target === elem ? ctl.close() : null)), dom.on('click', (ev, elem) => void (ev.target === elem ? ctl.close() : null)),
testId('lightbox'), testId('lightbox'),
]; ];
}, {
backerDomArgs: [
// Stack modal above the tutorial popup.
dom.style('z-index', vars.tutorialModalZIndex.toString()),
],
}); });
} }
} }

View File

@ -1,5 +1,5 @@
import {hoverTooltip} from 'app/client/ui/tooltips'; import {hoverTooltip} from 'app/client/ui/tooltips';
import {isNarrowScreen, isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars'; import {isNarrowScreen, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {Disposable, dom, DomArg, DomContents, IDisposable, makeTestId, Observable, styled} from 'grainjs'; import {Disposable, dom, DomArg, DomContents, IDisposable, makeTestId, Observable, styled} from 'grainjs';
@ -16,11 +16,15 @@ export interface PopupOptions {
onClose?: () => void; onClose?: () => void;
closeButton?: boolean; closeButton?: boolean;
autoHeight?: boolean; autoHeight?: boolean;
/** Defaults to false. */
stopClickPropagationOnMove?: boolean;
} }
export class FloatingPopup extends Disposable { export class FloatingPopup extends Disposable {
protected _isMinimized = Observable.create(this, false); protected _isMinimized = Observable.create(this, false);
private _isFinishingMove = false;
private _popupElement: HTMLElement | null = null; private _popupElement: HTMLElement | null = null;
private _popupMinimizeButtonElement: HTMLElement | null = null;
private _startX: number; private _startX: number;
private _startY: number; private _startY: number;
@ -33,6 +37,25 @@ export class FloatingPopup extends Disposable {
constructor(protected _options: PopupOptions = {}, private _args: DomArg[] = []) { constructor(protected _options: PopupOptions = {}, private _args: DomArg[] = []) {
super(); super();
if (_options.stopClickPropagationOnMove){
// weasel.js registers a 'click' listener that closes any open popups that
// are outside the click target. We capture the click event here, stopping
// propagation in a few scenarios where closing popups is undesirable.
window.addEventListener('click', (ev) => {
if (this._isFinishingMove) {
ev.stopPropagation();
this._isFinishingMove = false;
return;
}
if (this._popupMinimizeButtonElement?.contains(ev.target as Node)) {
ev.stopPropagation();
this._minimizeOrMaximize();
return;
}
}, {capture: true});
}
this._handleMouseDown = this._handleMouseDown.bind(this); this._handleMouseDown = this._handleMouseDown.bind(this);
this._handleMouseMove = this._handleMouseMove.bind(this); this._handleMouseMove = this._handleMouseMove.bind(this);
this._handleMouseUp = this._handleMouseUp.bind(this); this._handleMouseUp = this._handleMouseUp.bind(this);
@ -181,6 +204,7 @@ export class FloatingPopup extends Disposable {
} }
private _handleMouseUp() { private _handleMouseUp() {
this._isFinishingMove = true;
document.removeEventListener('mousemove', this._handleMouseMove); document.removeEventListener('mousemove', this._handleMouseMove);
document.removeEventListener('mouseup', this._handleMouseUp); document.removeEventListener('mouseup', this._handleMouseUp);
document.body.removeEventListener('mouseleave', this._handleMouseUp); document.body.removeEventListener('mouseleave', this._handleMouseUp);
@ -221,6 +245,11 @@ export class FloatingPopup extends Disposable {
this._popupElement!.style.top = `${newTop}px`; this._popupElement!.style.top = `${newTop}px`;
} }
private _minimizeOrMaximize() {
this._isMinimized.set(!this._isMinimized.get());
this._repositionPopup();
}
private _buildPopup() { private _buildPopup() {
const body = cssPopup( const body = cssPopup(
{tabIndex: '-1'}, {tabIndex: '-1'},
@ -231,7 +260,8 @@ export class FloatingPopup extends Disposable {
return cssResizeTopLayer( return cssResizeTopLayer(
cssTopHandle(testId('resize-handle')), cssTopHandle(testId('resize-handle')),
dom.on('mousedown', () => this._resize = true), dom.on('mousedown', () => this._resize = true),
dom.on('dblclick', () => { dom.on('dblclick', (e) => {
e.stopImmediatePropagation();
this._popupElement?.style.setProperty('--height', `${POPUP_MAX_HEIGHT}px`); this._popupElement?.style.setProperty('--height', `${POPUP_MAX_HEIGHT}px`);
this._repositionPopup(); this._repositionPopup();
}) })
@ -262,23 +292,27 @@ export class FloatingPopup extends Disposable {
}), }),
testId('close'), testId('close'),
), ),
cssPopupHeaderButton( this._popupMinimizeButtonElement = cssPopupHeaderButton(
isMinimized ? icon('Maximize'): icon('Minimize'), isMinimized ? icon('Maximize'): icon('Minimize'),
hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}), hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}),
dom.on('click', () => { dom.on('click', () => this._minimizeOrMaximize()),
this._isMinimized.set(!this._isMinimized.get());
this._repositionPopup();
}),
testId('minimize-maximize'), testId('minimize-maximize'),
), ),
// Disable dragging when a button in the header is clicked.
dom.on('mousedown', ev => ev.stopPropagation()),
dom.on('touchstart', ev => ev.stopPropagation()),
) )
]; ];
}), }),
dom.on('mousedown', this._handleMouseDown), dom.on('mousedown', this._handleMouseDown),
dom.on('touchstart', this._handleTouchStart), dom.on('touchstart', this._handleTouchStart),
dom.on('dblclick', () => this._minimizeOrMaximize()),
testId('header'), testId('header'),
), ),
dom.maybe(use => !use(this._isMinimized), () => this._buildContent()), cssPopupContent(
this._buildContent(),
cssPopupContent.cls('-minimized', this._isMinimized),
),
() => { window.addEventListener('resize', this._handleWindowResize); }, () => { window.addEventListener('resize', this._handleWindowResize); },
dom.onDispose(() => { dom.onDispose(() => {
document.removeEventListener('mousemove', this._handleMouseMove); document.removeEventListener('mousemove', this._handleMouseMove);
@ -344,7 +378,7 @@ const cssPopup = styled('div', `
flex-direction: column; flex-direction: column;
border: 2px solid ${theme.accentBorder}; border: 2px solid ${theme.accentBorder};
border-radius: 5px; border-radius: 5px;
z-index: 999; z-index: ${vars.floatingPopupZIndex};
--height: ${POPUP_MAX_HEIGHT}px; --height: ${POPUP_MAX_HEIGHT}px;
height: ${POPUP_HEIGHT}; height: ${POPUP_HEIGHT};
width: ${POPUP_WIDTH}; width: ${POPUP_WIDTH};
@ -441,10 +475,10 @@ const cssPopupHeaderButton = styled('div', `
const cssResizeTopLayer = styled('div', ` const cssResizeTopLayer = styled('div', `
position: absolute; position: absolute;
top: 0; top: -6px;
left: 0; left: 0;
right: 0; right: 0;
bottom: 70%; bottom: 28px;
z-index: 500; z-index: 500;
cursor: ns-resize; cursor: ns-resize;
`); `);
@ -465,3 +499,14 @@ const cssBottomHandle = styled(cssTopHandle, `
bottom: 0; bottom: 0;
left: 0; left: 0;
`); `);
const cssPopupContent = styled('div', `
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
&-minimized {
display: none;
}
`);

View File

@ -217,7 +217,7 @@ function workspaceMenu(home: HomeModel, ws: Workspace, renaming: Observable<Work
function deleteWorkspace() { function deleteWorkspace() {
confirmModal(t("Delete {{workspace}} and all included documents?", {workspace: ws.name}), t("Delete"), confirmModal(t("Delete {{workspace}} and all included documents?", {workspace: ws.name}), t("Delete"),
() => home.deleteWorkspace(ws.id, false), () => home.deleteWorkspace(ws.id, false),
t("Workspace will be moved to Trash.")); {explanation: t("Workspace will be moved to Trash.")});
} }
async function manageWorkspaceUsers() { async function manageWorkspaceUsers() {

View File

@ -59,7 +59,7 @@ export async function replaceTrunkWithFork(user: FullUser|null, doc: Document, a
} catch (e) { } catch (e) {
reportError(e); // For example: no write access on trunk. reportError(e); // For example: no write access on trunk.
} }
}, warningText); }, {explanation: warningText});
} }
// Show message in a modal with a `Sign up` button that redirects to the login page. // Show message in a modal with a `Sign up` button that redirects to the login page.

View File

@ -258,13 +258,13 @@ const cssDropdownStatusText = styled('div', `
color: ${theme.lightText}; color: ${theme.lightText};
`); `);
// z-index below is set above other assorted children of <body> which include z-index such as 999 // z-index below is set above other assorted children of <body>, which includes
// and 1050 (for new-style and old-style modals, for example). // indexes such as 999 for modals.
const cssSnackbarWrapper = styled('div', ` const cssSnackbarWrapper = styled('div', `
position: fixed; position: fixed;
bottom: 8px; bottom: 8px;
right: 8px; right: 8px;
z-index: 1100; z-index: ${vars.notificationZIndex};
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -326,7 +326,7 @@ const Container = styled('div', `
align-self: center; align-self: center;
border: 2px solid ${theme.accentBorder}; border: 2px solid ${theme.accentBorder};
border-radius: 3px; border-radius: 3px;
z-index: 1000; z-index: ${vars.onboardingPopupZIndex};
max-width: 490px; max-width: 490px;
position: relative; position: relative;
background-color: ${theme.popupBg}; background-color: ${theme.popupBg};
@ -423,7 +423,7 @@ const Overlay = styled('div', `
height: 100%; height: 100%;
top: 0; top: 0;
left: 0; left: 0;
z-index: 999; z-index: ${vars.onboardingBackdropZIndex};
overflow-y: auto; overflow-y: auto;
`); `);

View File

@ -105,9 +105,14 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti
confirmModal( confirmModal(
`You are about to remove your own access to this ${name}`, `You are about to remove your own access to this ${name}`,
'Remove my access', tryToSaveChanges, 'Remove my access', tryToSaveChanges,
{
explanation: (
'Once you have removed your own access, ' + 'Once you have removed your own access, ' +
'you will not be able to get it back without assistance ' + 'you will not be able to get it back without assistance ' +
`from someone else with sufficient access to the ${name}.`); `from someone else with sufficient access to the ${name}.`
),
}
);
} else { } else {
tryToSaveChanges().catch(reportError); tryToSaveChanges().catch(reportError);
} }

View File

@ -404,7 +404,7 @@ const cssColumnInfoTooltipButton = styled('div', `
const cssTooltip = styled('div', ` const cssTooltip = styled('div', `
position: absolute; position: absolute;
z-index: 5000; /* should be higher than a modal */ z-index: ${vars.tooltipZIndex}; /* should be higher than a modal */
background-color: ${theme.tooltipBg}; background-color: ${theme.tooltipBg};
border-radius: 3px; border-radius: 3px;
box-shadow: 0 0 2px rgba(0,0,0,0.5); box-shadow: 0 0 2px rgba(0,0,0,0.5);

View File

@ -134,6 +134,18 @@ export const vars = {
logoBg: new CustomProp('logo-bg', '#040404'), logoBg: new CustomProp('logo-bg', '#040404'),
logoSize: new CustomProp('logo-size', '22px 22px'), logoSize: new CustomProp('logo-size', '22px 22px'),
toastBg: new CustomProp('toast-bg', '#040404'), toastBg: new CustomProp('toast-bg', '#040404'),
/* Z indexes */
menuZIndex: new CustomProp('menu-z-index', '999'),
modalZIndex: new CustomProp('modal-z-index', '999'),
onboardingBackdropZIndex: new CustomProp('onboarding-backdrop-z-index', '999'),
onboardingPopupZIndex: new CustomProp('onboarding-popup-z-index', '1000'),
floatingPopupZIndex: new CustomProp('floating-popup-z-index', '1002'),
tutorialModalZIndex: new CustomProp('tutorial-modal-z-index', '1003'),
notificationZIndex: new CustomProp('notification-z-index', '1100'),
browserCheckZIndex: new CustomProp('browser-check-z-index', '5000'),
tooltipZIndex: new CustomProp('tooltip-z-index', '5000'),
// TODO: Add properties for remaining hard-coded z-indexes.
}; };
/** /**

View File

@ -65,7 +65,7 @@ export const cssMenuElem = styled('div', `
padding: 8px 0px 16px 0px; padding: 8px 0px 16px 0px;
box-shadow: 0 2px 20px 0 ${theme.menuShadow}; box-shadow: 0 2px 20px 0 ${theme.menuShadow};
min-width: 160px; min-width: 160px;
z-index: 999; z-index: ${vars.menuZIndex};
--weaseljs-selected-background-color: ${theme.menuItemSelectedBg}; --weaseljs-selected-background-color: ${theme.menuItemSelectedBg};
--weaseljs-menu-item-padding: 8px 24px; --weaseljs-menu-item-padding: 8px 24px;
background-color: ${theme.menuBg}; background-color: ${theme.menuBg};

View File

@ -137,6 +137,8 @@ export interface IModalOptions {
noEscapeKey?: boolean; noEscapeKey?: boolean;
// If set, clicking into background does not close dialog. // If set, clicking into background does not close dialog.
noClickAway?: boolean; noClickAway?: boolean;
// DOM arguments to pass to the modal backer.
backerDomArgs?: DomElementArg[];
// If given, call and wait for this before closing the dialog. If it returns false, don't close. // 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. // Error also prevents closing, and is reported as an unexpected error.
beforeClose?: () => Promise<boolean>; beforeClose?: () => Promise<boolean>;
@ -174,7 +176,13 @@ export function modal(
createFn: (ctl: IModalControl, owner: MultiHolder) => DomElementArg, createFn: (ctl: IModalControl, owner: MultiHolder) => DomElementArg,
options: IModalOptions = {} options: IModalOptions = {}
): void { ): void {
const {noEscapeKey, noClickAway, refElement = document.body, variant = 'fade-in'} = options; const {
noEscapeKey,
noClickAway,
refElement = document.body,
variant = 'fade-in',
backerDomArgs = [],
} = options;
function doClose() { function doClose() {
if (!modalDom.isConnected) { return; } if (!modalDom.isConnected) { return; }
@ -242,6 +250,7 @@ export function modal(
return dialogDom; return dialogDom;
}), }),
noClickAway ? null : dom.on('click', () => close()), noClickAway ? null : dom.on('click', () => close()),
...backerDomArgs,
); );
document.body.appendChild(modalDom); document.body.appendChild(modalDom);
@ -293,7 +302,10 @@ export interface ISaveModalOptions {
* 3. saveFunc: () => doSomething().catch((e) => { alert("BOOM"); throw new StayOpen(); }) * 3. saveFunc: () => doSomething().catch((e) => { alert("BOOM"); throw new StayOpen(); })
* If doSomething fails, an alert is shown, and the dialog stays open. * If doSomething fails, an alert is shown, and the dialog stays open.
*/ */
export function saveModal(createFunc: (ctl: IModalControl, owner: MultiHolder) => ISaveModalOptions) { export function saveModal(
createFunc: (ctl: IModalControl, owner: MultiHolder) => ISaveModalOptions,
modalOptions?: IModalOptions
) {
return modal((ctl, owner) => { return modal((ctl, owner) => {
const options = createFunc(ctl, owner); const options = createFunc(ctl, owner);
@ -321,7 +333,14 @@ export function saveModal(createFunc: (ctl: IModalControl, owner: MultiHolder) =
options.width && cssModalWidth(options.width), options.width && cssModalWidth(options.width),
options.modalArgs, options.modalArgs,
]; ];
}); }, modalOptions);
}
export interface ConfirmModalOptions {
explanation?: DomElementArg,
hideCancel?: boolean;
extraButtons?: DomContents;
modalOptions?: IModalOptions;
} }
/** /**
@ -333,8 +352,7 @@ export function confirmModal(
title: DomElementArg, title: DomElementArg,
btnText: DomElementArg, btnText: DomElementArg,
onConfirm: () => Promise<void>, onConfirm: () => Promise<void>,
explanation?: DomElementArg, {explanation, hideCancel, extraButtons, modalOptions}: ConfirmModalOptions = {},
{hideCancel, extraButtons}: {hideCancel?: boolean, extraButtons?: DomContents} = {},
): void { ): void {
return saveModal((ctl, owner): ISaveModalOptions => ({ return saveModal((ctl, owner): ISaveModalOptions => ({
title, title,
@ -344,7 +362,7 @@ export function confirmModal(
hideCancel, hideCancel,
width: 'normal', width: 'normal',
extraButtons, extraButtons,
})); }), modalOptions);
} }
@ -592,7 +610,7 @@ const cssModalBacker = styled('div', `
top: 0; top: 0;
left: 0; left: 0;
padding: 16px; padding: 16px;
z-index: 999; z-index: ${vars.modalZIndex};
background-color: ${theme.modalBackdrop}; background-color: ${theme.modalBackdrop};
overflow-y: auto; overflow-y: auto;
animation-name: ${cssFadeIn}; animation-name: ${cssFadeIn};

View File

@ -354,7 +354,12 @@ describe('DocTutorial', function () {
it('supports navigating to a specific slide', async function() { it('supports navigating to a specific slide', async function() {
const slide3 = await driver.find('.test-doc-tutorial-popup-slide-3'); const slide3 = await driver.find('.test-doc-tutorial-popup-slide-3');
assert.equal(await slide3.getAttribute('title'), 'Adding Columns and Rows'); await slide3.mouseMove();
await gu.waitToPass(
async () => assert.isTrue(await driver.find('.test-tooltip').isDisplayed()),
500
);
assert.equal(await driver.find('.test-tooltip').getText(), 'Adding Columns and Rows');
await slide3.click(); await slide3.click();
await driver.find('.test-doc-tutorial-popup-slide-3').click(); await driver.find('.test-doc-tutorial-popup-slide-3').click();
assert.equal( assert.equal(
@ -368,7 +373,12 @@ describe('DocTutorial', function () {
); );
const slide1 = await driver.find('.test-doc-tutorial-popup-slide-1'); const slide1 = await driver.find('.test-doc-tutorial-popup-slide-1');
assert.equal(await slide1.getAttribute('title'), 'Intro'); await slide1.mouseMove();
await gu.waitToPass(
async () => assert.isTrue(await driver.find('.test-tooltip').isDisplayed()),
500
);
assert.equal(await driver.find('.test-tooltip').getText(), 'Intro');
await slide1.click(); await slide1.click();
assert.equal( assert.equal(
await driver.find('.test-doc-tutorial-popup h1').getText(), await driver.find('.test-doc-tutorial-popup h1').getText(),
@ -392,11 +402,11 @@ describe('DocTutorial', function () {
assert.isFalse(await driver.find('.test-doc-tutorial-lightbox').isPresent()); assert.isFalse(await driver.find('.test-doc-tutorial-lightbox').isPresent());
}); });
it('can be minimized and maximized', async function() { it('can be minimized and maximized by clicking a button in the header', async function() {
await driver.find('.test-floating-popup-minimize-maximize').click(); await driver.find('.test-floating-popup-minimize-maximize').click();
assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed()); assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed());
assert.isFalse(await driver.find('.test-doc-tutorial-popup-body').isPresent()); assert.isFalse(await driver.find('.test-doc-tutorial-popup-body').isDisplayed());
assert.isFalse(await driver.find('.test-doc-tutorial-popup-footer').isPresent()); assert.isFalse(await driver.find('.test-doc-tutorial-popup-footer').isDisplayed());
await driver.find('.test-floating-popup-minimize-maximize').click(); await driver.find('.test-floating-popup-minimize-maximize').click();
assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed()); assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed());
@ -404,6 +414,18 @@ describe('DocTutorial', function () {
assert.isTrue(await driver.find('.test-doc-tutorial-popup-footer').isDisplayed()); assert.isTrue(await driver.find('.test-doc-tutorial-popup-footer').isDisplayed());
}); });
it('can be minimized and maximized by double clicking the header', async function() {
await driver.withActions(a => a.doubleClick(driver.find('.test-floating-popup-title')));
assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed());
assert.isFalse(await driver.find('.test-doc-tutorial-popup-body').isDisplayed());
assert.isFalse(await driver.find('.test-doc-tutorial-popup-footer').isDisplayed());
await driver.withActions(a => a.doubleClick(driver.find('.test-floating-popup-title')));
assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed());
assert.isTrue(await driver.find('.test-doc-tutorial-popup-body').isDisplayed());
assert.isTrue(await driver.find('.test-doc-tutorial-popup-footer').isDisplayed());
});
it('does not play an easter egg when opening an anchor link encoded with rr', async function() { it('does not play an easter egg when opening an anchor link encoded with rr', async function() {
await gu.getCell({rowNum: 1, col: 0}).click(); await gu.getCell({rowNum: 1, col: 0}).click();
const link = await gu.getAnchor(); const link = await gu.getAnchor();