gristlabs_grist-core/app/client/ui/OnBoardingPopups.ts
Alex Hall 1d1a9297f8 (core) Polish UI/UX of onboarding popups
Summary:
Replace Finish button with Previous and an X to close
Add keyboard shortcuts to tour popups
Change last Next button to Finish instead of disabling, can be triggered by Enter key.
Allow closing the tour and reopening in the same place.

Test Plan: only manual, need to confirm desired behaviour

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2950
2021-07-30 15:44:18 +02:00

425 lines
12 KiB
TypeScript

/**
* Utility to generate a series of onboarding popups. It is used to give users a short description
* of some elements of the UI. The first step is to create the list of messages following the
* `IOnBoardingMsg` interface. Then you have to attach each message to its corresponding element of
* the UI using the `attachOnBoardingMsg' dom method:
*
* Usage:
*
* // create the list of message
* const messages = [{id: 'add-new-btn', placement: 'right', buildDom: () => ... },
* {id: 'share-btn', buildDom: () => ... ];
*
*
* // attach each message to the corresponding element
* dom('div', 'Add New', ..., dom.cls('tour-add-new-btn'));
*
* // start
* startOnBoarding(message, onFinishCB);
*
* Note:
* - this module does UI only, saving which user has already seen the popups has to be handled by
* the caller. Pass an `onFinishCB` to handle when a user dimiss the popups.
*/
import { Disposable, dom, DomElementArg, makeTestId, styled, svg } from "grainjs";
import { createPopper, Placement } from '@popperjs/core';
import { FocusLayer } from 'app/client/lib/FocusLayer';
import * as Mousetrap from 'app/client/lib/Mousetrap';
import { bigBasicButton, bigPrimaryButton } from "app/client/ui2018/buttons";
import { colors, vars } from "app/client/ui2018/cssVars";
import range = require("lodash/range");
import {IGristUrlState} from "app/common/gristUrls";
import {urlState} from "app/client/models/gristUrlState";
import {delay} from "app/common/delay";
import {reportError} from "app/client/models/errors";
import {cssBigIcon, cssCloseButton} from "./ExampleCard";
const testId = makeTestId('test-onboarding-');
// Describes an onboarding popup. Each popup is uniquely identified by its id.
export interface IOnBoardingMsg {
// A CSS selector pointing to the reference element
selector: string,
// Title
title: DomElementArg,
// Body
body?: DomElementArg,
// If true show the message as a modal centered on the screen.
showHasModal?: boolean,
// The popper placement.
placement?: Placement,
// Adjusts the popup offset so that it is positioned relative to the content of the reference
// element. This is useful when the reference element has padding and no border (ie: such as
// icons). In which case, and when set to true, it will fill the gap between popups and the UI
// part it's pointing at. If `cropPadding` is falsy otherwise, the popup might look a bit distant.
cropPadding?: boolean,
// The popper offset.
offset?: [number, number],
// Skip the message
skip?: boolean;
// If present, will be passed to urlState().pushUrl() to navigate to the location defined by that state
urlState?: IGristUrlState;
}
export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: () => void) {
const ctl = new OnBoardingPopupsCtl(messages, onFinishCB);
ctl.start().catch(reportError);
}
class OnBoardingError extends Error {
public name = 'OnBoardingError';
constructor(message: string) {
super(message);
}
}
/**
* Current index in the list of messages.
* This allows closing the tour and reopening where you left off.
* Since it's a single global value, mixing unrelated tours
* (e.g. the generic welcome tour and a specific document tour)
* in a single page load won't work well.
*/
let ctlIndex = 0;
class OnBoardingPopupsCtl extends Disposable {
private _openPopupCtl: {close: () => void}|undefined;
private _overlay: HTMLElement;
private _arrowEl = buildArrow();
constructor(private _messages: IOnBoardingMsg[], private _onFinishCB: () => void) {
super();
if (this._messages.length === 0) {
throw new OnBoardingError('messages should not be an empty list');
}
// In case we're reopening after deleting some rows of GristDocTour,
// ensure ctlIndex is still within bounds
ctlIndex = Math.min(ctlIndex, this._messages.length - 1);
this.onDispose(() => {
this._openPopupCtl?.close();
});
}
public async start() {
this._showOverlay();
await this._move(0);
Mousetrap.setPaused(true);
this.onDispose(() => {
Mousetrap.setPaused(false);
});
}
private _finish() {
this._onFinishCB();
this.dispose();
}
private async _move(movement: number, maybeClose = false) {
const newIndex = ctlIndex + movement;
const entry = this._messages[newIndex];
if (!entry) {
if (maybeClose) {
// User finished the tour, close and restart from the beginning if they reopen
ctlIndex = 0;
this._finish();
}
return; // gone out of bounds, probably by keyboard shortcut
}
ctlIndex = newIndex;
if (entry.skip) {
// movement = 0 when starting a tour, make sure we don't get stuck in a loop
await this._move(movement || +1);
return;
}
// close opened popup if any
this._openPopupCtl?.close();
if (entry.urlState) {
await urlState().pushUrl(entry.urlState);
await delay(100); // make sure cursor is in correct place
}
if (entry.showHasModal) {
this._showHasModal();
} else {
await this._showHasPopup(movement);
}
}
private async _showHasPopup(movement: number) {
const content = this._buildPopupContent();
const entry = this._messages[ctlIndex];
const elem = document.querySelector<HTMLElement>(entry.selector);
const {placement} = entry;
// The element the popup refers to is not present. To the user we show nothing and simply skip
// it to the next.
if (!elem) {
console.warn(`On boarding tour: element ${entry.selector} not found!`);
// movement = 0 when starting a tour, make sure we don't get stuck in a loop
return this._move(movement || +1);
}
// Cleanup
function close() {
popper.destroy();
dom.domDispose(content);
content.remove();
}
this._openPopupCtl = {close};
document.body.appendChild(content);
this._addFocusLayer(content);
// Create a popper for positioning the popup content relative to the reference element
const adjacentPadding = entry.cropPadding ? this._getAdjacentPadding(elem, placement) : 0;
const popper = createPopper(elem, content, {
placement,
modifiers: [{
name: 'arrow',
options: {
element: this._arrowEl,
},
}, {
name: 'offset',
options: {
offset: [0, 12 - adjacentPadding],
}
}],
});
}
private _addFocusLayer(container: HTMLElement) {
dom.autoDisposeElem(container, new FocusLayer({
defaultFocusElem: container,
allowFocus: (elem) => (elem !== document.body)
}));
}
// Get the padding length for the side that will be next to the popup.
private _getAdjacentPadding(elem: HTMLElement, placement?: Placement) {
if (placement) {
let padding = '';
if (placement.includes('bottom')) {
padding = getComputedStyle(elem).paddingBottom;
}
else if (placement.includes('top')) {
padding = getComputedStyle(elem).paddingTop;
}
else if (placement.includes('left')) {
padding = getComputedStyle(elem).paddingLeft;
}
else if (placement.includes('right')) {
padding = getComputedStyle(elem).paddingRight;
}
// Note: getComputedStyle return value in pixel, hence no need to handle other unit. See here
// for reference:
// https://developer.mozilla.org/en-US/docs/Web/API/Window/getComputedStyle#notes.
if (padding && padding.endsWith('px')) {
return Number(padding.slice(0, padding.length - 2));
}
}
return 0;
}
private _showHasModal() {
const content = this._buildPopupContent();
dom.update(this._overlay, content);
this._addFocusLayer(content);
function close() {
content.remove();
dom.domDispose(content);
}
this._openPopupCtl = {close};
}
private _buildPopupContent() {
return Container(
{tabindex: '-1'},
this._arrowEl,
ContentWrapper(
cssCloseButton(cssBigIcon('CrossBig'),
dom.on('click', () => this._finish()),
testId('close'),
),
cssTitle(this._messages[ctlIndex].title),
cssBody(this._messages[ctlIndex].body),
this._buildFooter(),
testId('popup'),
),
dom.onKeyDown({
Escape: () => this._finish(),
ArrowLeft: () => this._move(-1),
ArrowRight: () => this._move(+1),
Enter: () => this._move(+1, true),
}),
);
}
private _buildFooter() {
const nSteps = this._messages.length;
const isLastStep = ctlIndex === nSteps - 1;
const isFirstStep = ctlIndex === 0;
return Footer(
ProgressBar(
range(nSteps).map((i) => Dot(Dot.cls('-done', i > ctlIndex))),
),
Buttons(
bigBasicButton(
'Previous', testId('previous'),
dom.on('click', () => this._move(-1)),
dom.prop('disabled', isFirstStep),
{style: `margin-right: 8px; visibility: ${isFirstStep ? 'hidden' : 'visible'}`},
),
bigPrimaryButton(
isLastStep ? 'Finish' : 'Next', testId('next'),
dom.on('click', () => this._move(+1, true)),
),
)
);
}
private _showOverlay() {
document.body.appendChild(this._overlay = Overlay());
this.onDispose(() => {
document.body.removeChild(this._overlay);
dom.domDispose(this._overlay);
});
}
}
function buildArrow() {
return ArrowContainer(
svg('svg', { style: 'width: 13px; height: 34px;' },
svg('path', {'d': 'M 2 19 h 13 v 18 Z'}))
);
}
const Container = styled('div', `
align-self: center;
border: 2px solid ${colors.lightGreen};
border-radius: 3px;
z-index: 1000;
max-width: 490px;
position: relative;
background-color: white;
box-shadow: 0 2px 18px 0 rgba(31,37,50,0.31), 0 0 1px 0 rgba(76,86,103,0.24);
outline: unset;
`);
function sideSelectorChunk(side: 'top'|'bottom'|'left'|'right') {
return `.${Container.className}[data-popper-placement^=${side}]`;
}
const ArrowContainer = styled('div', `
position: absolute;
& path {
stroke: ${colors.lightGreen};
stroke-width: 2px;
fill: white;
}
${sideSelectorChunk('top')} > & {
bottom: -26px;
}
${sideSelectorChunk('bottom')} > & {
top: -23px;
}
${sideSelectorChunk('right')} > & {
left: -12px;
}
${sideSelectorChunk('left')} > & {
right: -12px;
}
${sideSelectorChunk('top')} svg {
transform: rotate(-90deg);
}
${sideSelectorChunk('bottom')} svg {
transform: rotate(90deg);
}
${sideSelectorChunk('left')} svg {
transform: scalex(-1);
}
`);
const ContentWrapper = styled('div', `
position: relative;
padding: 32px;
background-color: white;
`);
const Footer = styled('div', `
display: flex;
flex-direction: row;
margin-top: 32px;
justify-content: space-between;
`);
const ProgressBar = styled('div', `
display: flex;
flex-direction: row;
`);
const Buttons = styled('div', `
display: flex;
flex-directions: row;
`);
const Dot = styled('div', `
width: 6px;
height: 6px;
border-radius: 3px;
margin-right: 12px;
align-self: center;
background-color: ${colors.lightGreen};
&-done {
background-color: ${colors.darkGrey};
}
`);
const Overlay = styled('div', `
position: fixed;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 999;
overflow-y: auto;
`);
const cssTitle = styled('div', `
font-size: ${vars.xxxlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
color: ${colors.dark};
margin: 0 0 16px 0;
line-height: 32px;
`);
const cssBody = styled('div', `
`);