/** * External Dependencies */ import { useMobileBreakpoint } from '@automattic/viewport-react'; import { useEffect, useState, useCallback, useMemo, useRef } from '@wordpress/element'; import classnames from 'classnames'; import { usePopper } from 'react-popper'; /** * Internal Dependencies */ import useStepTracking from '../hooks/use-step-tracking'; import { classParser } from '../utils'; import { liveResizeModifier } from '../utils/live-resize-modifier'; import KeyboardNavigation from './keyboard-navigation'; import TourKitMinimized from './tour-kit-minimized'; import Overlay from './tour-kit-overlay'; import Spotlight from './tour-kit-spotlight'; import TourKitStep from './tour-kit-step'; import type { Callback, Config } from '../types'; const handleCallback = ( currentStepIndex: number, callback?: Callback ) => { typeof callback === 'function' && callback( currentStepIndex ); }; interface Props { config: Config; } const TourKitFrame: React.FunctionComponent< Props > = ( { config } ) => { const [ currentStepIndex, setCurrentStepIndex ] = useState( 0 ); const [ initialFocusedElement, setInitialFocusedElement ] = useState< HTMLElement | null >( null ); const [ isMinimized, setIsMinimized ] = useState( config.isMinimized ?? false ); const [ popperElement, setPopperElement ] = useState< HTMLElement | null >( null ); const [ tourReady, setTourReady ] = useState( false ); const tourContainerRef = useRef( null ); const isMobile = useMobileBreakpoint(); const lastStepIndex = config.steps.length - 1; const referenceElements = config.steps[ currentStepIndex ].referenceElements; const referenceElementSelector = referenceElements?.[ isMobile ? 'mobile' : 'desktop' ] || referenceElements?.desktop; const referenceElement = referenceElementSelector ? document.querySelector< HTMLElement >( referenceElementSelector ) : null; useEffect( () => { if ( config.isMinimized ) { setIsMinimized( true ); } }, [ config.isMinimized ] ); const showArrowIndicator = useCallback( () => { if ( config.options?.effects?.arrowIndicator === false ) { return false; } return !! ( referenceElement && ! isMinimized && tourReady ); }, [ config.options?.effects?.arrowIndicator, isMinimized, referenceElement, tourReady ] ); const showSpotlight = useCallback( () => { if ( ! config.options?.effects?.spotlight ) { return false; } return ! isMinimized; }, [ config.options?.effects?.spotlight, isMinimized ] ); const showOverlay = useCallback( () => { if ( showSpotlight() || ! config.options?.effects?.overlay ) { return false; } return ! isMinimized; }, [ config.options?.effects?.overlay, isMinimized, showSpotlight ] ); const handleDismiss = useCallback( ( source: string ) => { return () => { config.closeHandler( config.steps, currentStepIndex, source ); }; }, [ config, currentStepIndex ] ); const handleNextStepProgression = useCallback( () => { let newStepIndex = currentStepIndex; if ( lastStepIndex > currentStepIndex ) { newStepIndex = currentStepIndex + 1; setCurrentStepIndex( newStepIndex ); } handleCallback( newStepIndex, config.options?.callbacks?.onNextStep ); }, [ config.options?.callbacks?.onNextStep, currentStepIndex, lastStepIndex ] ); const handlePreviousStepProgression = useCallback( () => { let newStepIndex = currentStepIndex; if ( currentStepIndex > 0 ) { newStepIndex = currentStepIndex - 1; setCurrentStepIndex( newStepIndex ); } handleCallback( newStepIndex, config.options?.callbacks?.onPreviousStep ); }, [ config.options?.callbacks?.onPreviousStep, currentStepIndex ] ); const handleGoToStep = useCallback( ( stepIndex: number ) => { setCurrentStepIndex( stepIndex ); handleCallback( stepIndex, config.options?.callbacks?.onGoToStep ); }, [ config.options?.callbacks?.onGoToStep, currentStepIndex ] ); const handleMinimize = useCallback( () => { setIsMinimized( true ); handleCallback( currentStepIndex, config.options?.callbacks?.onMinimize ); }, [ config.options?.callbacks?.onMinimize, currentStepIndex ] ); const handleMaximize = useCallback( () => { setIsMinimized( false ); handleCallback( currentStepIndex, config.options?.callbacks?.onMaximize ); }, [ config.options?.callbacks?.onMaximize, currentStepIndex ] ); const { styles: popperStyles, attributes: popperAttributes, update: popperUpdate, } = usePopper( referenceElement, popperElement, { strategy: 'fixed', placement: config?.placement ?? 'bottom', modifiers: [ { name: 'preventOverflow', options: { rootBoundary: 'document', padding: 16, // same as the left/margin of the tour frame }, }, { name: 'arrow', options: { padding: 12, }, }, { name: 'offset', options: { offset: [ 0, showArrowIndicator() ? 12 : 10 ], }, }, { name: 'flip', options: { fallbackPlacements: [ 'top', 'left', 'right' ], }, }, useMemo( () => liveResizeModifier( config.options?.effects?.liveResize ), [ config.options?.effects?.liveResize ] ), ...( config.options?.popperModifiers || [] ), ], } ); const stepRepositionProps = ! isMinimized && referenceElement && tourReady ? { style: popperStyles?.popper, ...popperAttributes?.popper, } : null; const arrowPositionProps = ! isMinimized && referenceElement && tourReady ? { style: popperStyles?.arrow, ...popperAttributes?.arrow, } : null; /* * Focus first interactive element when step renders. */ useEffect( () => { setTimeout( () => initialFocusedElement?.focus() ); }, [ initialFocusedElement ] ); /* * Fixes issue with Popper misplacing the instance on mount * See: https://stackoverflow.com/questions/65585859/react-popper-incorrect-position-on-mount */ useEffect( () => { // If no reference element to position step near if ( ! referenceElement ) { setTourReady( true ); return; } setTourReady( false ); if ( popperUpdate ) { popperUpdate() .then( () => setTourReady( true ) ) .catch( () => setTourReady( true ) ); } }, [ popperUpdate, referenceElement ] ); useEffect( () => { if ( referenceElement && config.options?.effects?.autoScroll ) { referenceElement.scrollIntoView( config.options.effects.autoScroll ); } }, [ config.options?.effects?.autoScroll, referenceElement ] ); const classes = classnames( 'tour-kit-frame', isMobile ? 'is-mobile' : 'is-desktop', { 'is-visible': tourReady }, classParser( config.options?.classNames ) ); useStepTracking( currentStepIndex, config.options?.callbacks?.onStepViewOnce ); useEffect( () => { if ( config.options?.callbacks?.onStepView ) { handleCallback( currentStepIndex, config.options?.callbacks?.onStepView ); } }, [ config.options?.callbacks?.onStepView, currentStepIndex ] ); return ( <>
{ showOverlay() && } { showSpotlight() && ( ) }
) } > { showArrowIndicator() && (
) } /> ) } { ! isMinimized ? ( ) : ( ) }
); }; export default TourKitFrame;