/** * WordPress dependencies */ import { Button } from '@safe-wordpress/components'; import { useSelect } from '@safe-wordpress/data'; import { useState, useEffect } from '@safe-wordpress/element'; import { _x } from '@safe-wordpress/i18n'; /** * External dependencies */ import createIntroJs from 'intro.js'; import { store as NC_DATA } from '@nelio-content/data'; import { getValue, setValue } from '@nelio-content/utils'; import type { IntroJs, Step as IntroJsStep } from 'intro.js'; import type { Dict, TutorialStep, Walkthrough } from '@nelio-content/types'; /** * Internal dependencies */ import './style.scss'; export type ContextualHelpProps = { readonly component?: ( props: ComponentProps ) => JSX.Element | null; readonly context?: string; readonly walkthrough: Walkthrough; readonly autostart?: boolean; readonly delay?: number; }; type ComponentProps = { readonly className: string; readonly runWalkthrough: () => void; }; export const ContextualHelp = ( { component, context = '', walkthrough = [], autostart = false, delay = 250, }: ContextualHelpProps ): JSX.Element | null => { const runWalkthrough = useWalkthroughEffect( { context, steps: walkthrough, autostart, delay, } ); if ( ! runWalkthrough ) { return null; } const Component = component || DefaultComponent; return ( ); }; const DefaultComponent = ( { className, runWalkthrough }: ComponentProps ) => ( ); // ===== // HOOKS // ===== function useWalkthroughEffect( { context, steps, autostart, delay, }: { readonly context: string; readonly steps: ReadonlyArray< TutorialStep >; readonly autostart: boolean; readonly delay: number; } ): false | ( () => void ) { const isAutoRunEnabled = useSelect( ( select ) => select( NC_DATA ).areAutoTutorialsEnabled(), [] ); const [ isRunning, setRunning ] = useState( false ); useEffect( () => { if ( ( !! steps.length && ! autostart ) || ! shouldRunOnStartup( context ) || ! isAutoRunEnabled ) { return; } const timeout = setTimeout( () => setRunning( true ), delay ); return () => clearTimeout( timeout ); } ); useEffect( () => { if ( ! isRunning ) { return; } setValue( `is-guide-${ context }-disabled`, true ); const lastStep: TutorialStep = { title: _x( 'Enjoy!', 'user', 'nelio-content' ), intro: _x( 'That’s all! If you want to view this guide again, please click here.', 'user', 'nelio-content' ), active: () => !! document.querySelector( `.nelio-content-contextual-help__${ context }-button` ), element: () => document.querySelector( `.nelio-content-contextual-help__${ context }-button` ), }; const finalSteps = [ ...steps, lastStep ] .filter( ( s: TutorialStep ) => ! ( 'active' in s ) || !! s.active?.() ) .map( tutorialStepToIntroStep ); const walkthrough = createIntroJs().setOptions( { buttonClass: 'button button-secondary nelio-content-walkthrough-button', disableInteraction: true, exitOnOverlayClick: false, overlayOpacity: 0.8, tooltipClass: 'nelio-content-walkthrough-tooltip', steps: finalSteps, } ); walkthrough.onbeforechange( () => applyClicks( walkthrough ) ); walkthrough.onafterchange( () => updateNavigationButtons( walkthrough, finalSteps.length ) ); walkthrough.onexit( () => setRunning( false ) ); walkthrough.start(); }, [ isRunning, context, steps ] ); return !! steps.length && ( () => setRunning( true ) ); } // ======= // HELPERS // ======= function tutorialStepToIntroStep( s: TutorialStep ): IntroJsStep & { extraNelioContent: TutorialStep } { const element = s.element?.(); return { intro: s.intro, title: s.title, element: element || undefined, extraNelioContent: s, }; } function shouldRunOnStartup( context: string ): boolean { return ! getValue( `is-guide-${ context }-disabled`, false ); } function applyClicks( introjs: IntroJs ): void { if ( ! introjs ) { return; } const index = introjs.currentStep() || -1; const steps = ( introjs as unknown as Dict< unknown > ) ._introItems as ReadonlyArray< Partial< { extraNelioContent: TutorialStep } > >; const step = steps[ index ]; step?.extraNelioContent?.elementToClick?.()?.click(); if ( step?.extraNelioContent?.element ) { ( step as Dict ).element = step.extraNelioContent.element() || undefined; } } function updateNavigationButtons( introjs: IntroJs, totalSteps: number ): void { const { prev, next, skip } = getNavigationButtons( introjs ); if ( ! prev || ! next || ! skip ) { return; } prev.textContent = _x( 'Prev', 'command', 'nelio-content' ); prev.style.display = isFirstStep( introjs ) ? 'none' : 'block'; skip.style.display = isFirstStep( introjs ) ? 'block' : 'none'; if ( isLastStep( introjs, totalSteps ) ) { next.classList.add( 'button-primary' ); next.classList.remove( 'button-secondary' ); next.textContent = _x( 'Close', 'command', 'nelio-content' ); } else if ( isFirstStep( introjs ) ) { next.classList.add( 'button-primary' ); next.textContent = _x( 'Start', 'command', 'nelio-content' ); } else { next.classList.remove( 'button-primary' ); next.classList.add( 'button-secondary' ); next.textContent = _x( 'Next', 'command', 'nelio-content' ); } } type NavigationButtons = { readonly prev?: HTMLElement; readonly next?: HTMLElement; readonly skip?: HTMLElement; }; function getNavigationButtons( introjs: IntroJs ): NavigationButtons { const buttons: ReadonlyArray< HTMLElement > = Array.from( document.querySelectorAll( '.nelio-content-walkthrough-button' ) ); const prev = buttons[ 0 ]; const next = buttons[ 1 ]; if ( ! prev || ! next ) { return {}; } const skip = getSkipButton( introjs, prev ); return { prev, next, skip }; } function getSkipButton( introjs: IntroJs, prev: HTMLElement ): HTMLElement { const existing = document.querySelector< HTMLElement >( '.nelio-content-walkthrough-skip' ); if ( existing ) { return existing; } const skip = document.createElement( 'a' ); skip.setAttribute( 'role', 'button' ); skip.setAttribute( 'tabindex', '0' ); skip.className = 'button button-secondary nelio-content-walkthrough-skip'; skip.textContent = _x( 'Skip', 'command', 'nelio-content' ); skip.style.float = 'left'; skip.addEventListener( 'click', () => introjs.exit() ); prev.parentNode?.insertBefore( skip, prev ); return skip; } function isFirstStep( introjs: IntroJs ): boolean { return ! introjs.currentStep(); } function isLastStep( introjs: IntroJs, totalSteps: number ): boolean { return introjs.currentStep() === totalSteps - 1; }