/** * 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', buildDom: () => dom('div', 'Adds New button let's you ...')}, * {id: 'share-btn', buildDom: () => ... ]; * * * // attach each message to the corresponding element * dom('div', 'Add New', ..., attachOnBoardingMsg('add-new-btn', {placement: 'right'})); * * // 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, DomElementMethod, makeTestId, styled, svg } from "grainjs"; import { createPopper, Placement, Options as PopperOptions } from '@popperjs/core'; import { pull, range } from "lodash"; import { bigBasicButton, bigPrimaryButton } from "../ui2018/buttons"; import { colors } from "../ui2018/cssVars"; const testId = makeTestId('test-onboarding-'); // Describes an onboarding popup. Each popup is uniquely identified by its id. export interface IOnBoardingMsg { // Identifies one message id: string, // Build the popup's content buildDom: () => HTMLElement, } export function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: () => void) { const ctl = new OnBoardingPopupsCtl(messages, onFinishCB); ctl.start(); } // Onboarding popup options. export interface IOnBoardingPopupOptions { placement?: Placement; } function attachOnBoardingElem(elem: HTMLElement, messageId: string, opts: IOnBoardingPopupOptions) { const val = {elem, messageId, ...opts}; registry.push(val); dom.onDisposeElem(elem, () => pull(registry, val)); } // A dom method that let you attach an boarding message to an element. This causes the onboarding // message to be shown in a tooltip like popup pointing at this element. More info in the module // description. export function attachOnBoardingMsg(messageId: string, opts: IOnBoardingPopupOptions): DomElementMethod { return (elem) => attachOnBoardingElem(elem, messageId, opts); } // Onboarding popup's options. interface IOnboardingRegistryItem { // the message id messageId: string; // The popper placement, optional. placement?: Placement, // the element, elem: HTMLElement, } // List of all registered element const registry: Array = []; class OnBoardingPopupsCtl extends Disposable { private _index = -1; 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 Error('messages should not be an empty list'); } this.onDispose(() => { this._openPopupCtl?.close(); }); } public start(): void { this._showOverlay(); this._next(); } private _finish() { this._onFinishCB(); this.dispose(); } private _next(): void { this._index = this._index + 1; const {id} = this._messages[this._index]; const entry = registry.find((opts) => opts.messageId === id); if (!entry) { throw new Error(`Missing on-boarding entry for message: ${id}`); } const {elem, placement} = entry; // close opened popup if any this._openPopupCtl?.close(); // Cleanup function close() { popper.destroy(); dom.domDispose(content); content.remove(); } // Add the content element const content = this._buildPopupContent(); document.body.appendChild(content); // Create a popper for positioning the popup content relative to the reference element const popperOptions: Partial = { placement }; const popper = createPopper(elem, content, { ...popperOptions, modifiers: [{ name: 'arrow', options: { element: this._arrowEl, }, }, { name: 'offset', options: { offset: [-12, 12], } }], }); this._openPopupCtl = {close}; } private _buildPopupContent() { return Container(this._arrowEl, ContentWrapper( this._messages[this._index].buildDom(), this._buildFooter(), testId('popup'), )); } private _buildFooter() { const nSteps = this._messages.length; const isLastStep = this._index === nSteps - 1; return Footer( ProgressBar( range(nSteps).map((i) => Dot(Dot.cls('-done', i > this._index))), ), Buttons( bigBasicButton( 'Finish', testId('finish'), dom.on('click', () => this._finish()), {style: 'margin-right: 8px;'}, ), bigPrimaryButton( 'Next', testId('next'), dom.on('click', () => this._next()), dom.prop('disabled', isLastStep), ), ) ); } 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: 14px; height: 36px;' }, svg('path', {'d': 'M 2 16 h 12 v 16 Z'})) ); } const Container = styled('div', ` 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); `); 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: -24px; } ${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; width: 100%; height: 100%; top: 0; left: 0; z-index: 999; overflow-y: auto; `);