UNPKG

@geosolutions/react-joyride

Version:

Create walkthroughs and guided tours for your apps

1,314 lines (1,157 loc) 39.1 kB
import React from 'react'; import scroll from 'scroll'; import autobind from 'react-autobind'; import nested from 'nested-property'; import { getRootEl, getBoundingClientRectFromElement, logger, sanitizeSelector, getDocHeight } from 'scripts/utils'; import { ResizeSensor } from 'css-element-queries'; import Beacon from 'scripts/Beacon'; import Tooltip from 'scripts/Tooltip'; import 'styles/react-joyride.scss'; const defaultState = { action: '', index: 0, isRunning: false, isTourSkipped: false, shouldRedraw: true, shouldRenderTooltip: false, shouldRun: false, standaloneData: false, // The standalone tooltip data xPos: -1000, yPos: -1000 }; const callbackTypes = { STEP_BEFORE: 'step:before', BEACON_BEFORE: 'beacon:before', BEACON_TRIGGER: 'beacon:trigger', TOOLTIP_BEFORE: 'tooltip:before', STEP_AFTER: 'step:after', STANDALONE_BEFORE: 'standalone:before', STANDALONE_AFTER: 'standalone:after', OVERLAY: 'overlay:click', HOLE: 'hole:click', FINISHED: 'finished', TARGET_NOT_FOUND: 'error:target_not_found' }; const DEFAULTS = { position: 'top', minWidth: 290 }; let hasTouch = false; let resizeSensorTargetElem = null; class Joyride extends React.Component { constructor(props) { super(props); autobind(this); this.state = { ...defaultState }; this.listeners = { tooltips: {} }; } static propTypes = { allowClicksThruHole: React.PropTypes.bool, autoResizeHole: React.PropTypes.bool, autoStart: React.PropTypes.bool, callback: React.PropTypes.func, debug: React.PropTypes.bool, disableOverlay: React.PropTypes.bool, holePadding: React.PropTypes.number, keyboardNavigation: React.PropTypes.bool, locale: React.PropTypes.object, offsetParentSelector: React.PropTypes.string, resizeDebounce: React.PropTypes.bool, resizeDebounceDelay: React.PropTypes.number, run: React.PropTypes.bool, scrollOffset: React.PropTypes.number, scrollToFirstStep: React.PropTypes.bool, scrollToSteps: React.PropTypes.bool, showBackButton: React.PropTypes.bool, showOverlay: React.PropTypes.bool, showSkipButton: React.PropTypes.bool, showStepsProgress: React.PropTypes.bool, stepIndex: React.PropTypes.number, steps: React.PropTypes.array, tooltipOffset: React.PropTypes.number, type: React.PropTypes.string }; static defaultProps = { allowClicksThruHole: false, autoResizeHole: false, autoStart: false, debug: false, disableOverlay: false, holePadding: 5, keyboardNavigation: true, locale: { back: 'Back', close: 'Close', last: 'Last', next: 'Next', skip: 'Skip' }, offsetParentSelector: 'body', resizeDebounce: false, resizeDebounceDelay: 200, run: false, scrollOffset: 20, scrollToFirstStep: false, scrollToSteps: true, showBackButton: true, showOverlay: true, showSkipButton: false, showStepsProgress: false, stepIndex: 0, steps: [], tooltipOffset: 15, type: 'single' }; componentDidMount() { const { autoStart, keyboardNavigation, resizeDebounce, resizeDebounceDelay, run, steps, type } = this.props; logger({ type: 'joyride:initialized', msg: [this.props], debug: this.props.debug, }); const stepsAreValid = this.checkStepsValidity(steps); if (steps && stepsAreValid && run) { this.start(autoStart); } if (resizeDebounce) { let timeoutId; this.listeners.resize = () => { clearTimeout(timeoutId); timeoutId = setTimeout(() => { timeoutId = null; this.calcPlacement(); }, resizeDebounceDelay); }; } else { this.listeners.resize = () => { this.calcPlacement(); }; } window.addEventListener('resize', this.listeners.resize); /* istanbul ignore else */ if (keyboardNavigation && type === 'continuous') { this.listeners.keyboard = this.onKeyboardNavigation; document.body.addEventListener('keydown', this.listeners.keyboard); } window.addEventListener('touchstart', function setHasTouch() { hasTouch = true; // Remove event listener once fired, otherwise it'll kill scrolling // performance window.removeEventListener('touchstart', setHasTouch); }, false); } componentWillReceiveProps(nextProps) { logger({ type: 'joyride:willReceiveProps', msg: [nextProps], debug: nextProps.debug, }); const { isRunning, shouldRun, standaloneData } = this.state; const { keyboardNavigation, run, steps, stepIndex } = this.props; const stepsChanged = (nextProps.steps !== steps); const stepIndexChanged = (nextProps.stepIndex !== stepIndex && nextProps.stepIndex !== this.state.index); const runChanged = (nextProps.run !== run); let shouldStart = false; let didStop = false; const hasNextStep = Boolean(nextProps.steps[nextProps.stepIndex]); const nextStep = hasNextStep ? nextProps.steps[nextProps.stepIndex] : null; const nextTargetElement = hasNextStep ? this.getStepTargetElement(nextStep) : null; // if resizeSensor exists and the target element changed - detach sensor from old element if (!hasNextStep || nextStep.autoResizeHole === false || (nextProps.autoResizeHole === false && nextStep.autoResizeHole !== true) || resizeSensorTargetElem !== nextTargetElement) { if (resizeSensorTargetElem !== null) { ResizeSensor.detach(resizeSensorTargetElem, this.targetResizeCallback); resizeSensorTargetElem = null; } } if (stepsChanged && this.checkStepsValidity(nextProps.steps)) { // Removed all steps, so reset if (!nextProps.steps || !nextProps.steps.length) { this.reset(); } // Start the joyride if steps were added for the first time, and run prop is true else if (!steps.length && nextProps.run) { shouldStart = true; } } /* istanbul ignore else */ if (runChanged) { // run prop was changed to off, so stop the joyride if (run && !nextProps.run) { this.stop(); didStop = true; } // run prop was changed to on, so start the joyride else if (!run && nextProps.run) { shouldStart = true; } // Was not playing, but should, and isn't a standaloneData else if (!isRunning && (shouldRun && !standaloneData)) { shouldStart = true; } } /* istanbul ignore else */ if (stepIndexChanged) { const hasStep = nextProps.steps[nextProps.stepIndex]; const shouldDisplay = hasStep && nextProps.autoStart; if (runChanged && shouldStart) { this.start(nextProps.autoStart, nextProps.steps, nextProps.stepIndex); } // Next prop is set to run, and the index has changed, but for some reason joyride is not running // (maybe this is because of a target not mounted, and the app wants to skip to another step) else if (nextProps.run && !isRunning) { this.start(nextProps.autoStart, nextProps.steps, nextProps.stepIndex); } else if (!didStop) { this.toggleTooltip({ show: shouldDisplay, index: nextProps.stepIndex, steps: nextProps.steps, action: 'jump' }); } } // Did not change the index, but need to start up the joyride else if (shouldStart) { this.start(nextProps.autoStart, nextProps.steps); } // Update keyboard listeners if necessary /* istanbul ignore else */ if ( !this.listeners.keyboard && ((!keyboardNavigation && nextProps.keyboardNavigation) || keyboardNavigation) && nextProps.type === 'continuous' ) { this.listeners.keyboard = this.onKeyboardNavigation; document.body.addEventListener('keydown', this.listeners.keyboard); } else if ( this.listeners.keyboard && keyboardNavigation && (!nextProps.keyboardNavigation || nextProps.type !== 'continuous') ) { document.body.removeEventListener('keydown', this.listeners.keyboard); delete this.listeners.keyboard; } } componentWillUpdate(nextProps, nextState) { const { index, isRunning, shouldRenderTooltip, standaloneData } = this.state; const { steps } = this.props; const { steps: nextSteps } = nextProps; const step = steps[index]; const nextStep = nextSteps[nextState.index]; const hasRenderedTarget = Boolean(this.getStepTargetElement(nextStep)); // Standalone tooltip is being turned on if (!standaloneData && nextState.standaloneData) { this.triggerCallback({ type: callbackTypes.STANDALONE_BEFORE, step: nextState.standaloneData }); } // Standalone tooltip is being turned off else if (standaloneData && !nextState.standaloneData) { this.triggerCallback({ type: callbackTypes.STANDALONE_AFTER, step: standaloneData }); } // Tried to start, but something went wrong and we're not actually running if (nextState.action === 'start' && !nextState.isRunning) { // There's a step to use, but there's no target in the DOM if (nextStep && !hasRenderedTarget) { console.warn('Target not mounted', nextStep, nextState.action); //eslint-disable-line no-console this.triggerCallback({ action: 'start', index: nextState.index, type: callbackTypes.TARGET_NOT_FOUND, step: nextStep, }); } } // Started running from the beginning (the current index is 0) if ((!isRunning && nextState.isRunning) && nextState.index === 0) { this.triggerCallback({ action: 'start', index: nextState.index, type: callbackTypes.STEP_BEFORE, step: nextStep }); // Not showing a tooltip yet, so we're going to show a beacon instead /* istanbul ignore else */ if (!nextState.shouldRenderTooltip) { this.triggerCallback({ action: 'start', index: nextState.index, type: callbackTypes.BEACON_BEFORE, step: nextStep }); } } // Joyride was running (it might still be), and the index has been changed if (isRunning && nextState.index !== index) { this.triggerCallback({ action: nextState.action, index, type: callbackTypes.STEP_AFTER, step }); // Attempted to advance to a step with a target that cannot be found /* istanbul ignore else */ if (nextStep && !hasRenderedTarget) { console.warn('Target not mounted', nextStep, nextState.action); //eslint-disable-line no-console this.triggerCallback({ action: nextState.action, index: nextState.index, type: callbackTypes.TARGET_NOT_FOUND, step: nextStep, }); } // There's a next step and the index is > 0 // (which means STEP_BEFORE wasn't sent as part of the start handler above) else if (nextStep && nextState.index) { this.triggerCallback({ action: nextState.action, index: nextState.index, type: callbackTypes.STEP_BEFORE, step: nextStep }); } } // Running, and a tooltip is being turned on/off or the index is changing if (nextState.isRunning && (shouldRenderTooltip !== nextState.shouldRenderTooltip || nextState.index !== index)) { // Going to show a tooltip if (nextState.shouldRenderTooltip) { this.triggerCallback({ action: nextState.action || (nextState.index === 0 ? 'autostart' : ''), index: nextState.index, type: callbackTypes.TOOLTIP_BEFORE, step: nextStep }); } // Going to show a beacon else { this.triggerCallback({ action: nextState.action, index: nextState.index, type: callbackTypes.BEACON_BEFORE, step: nextStep }); } } // Joyride was changed to a step index which doesn't exist (hit the end) if (!nextState.isRunning && nextSteps.length && index !== nextState.index && !nextStep) { this.triggerCallback({ action: nextState.action, type: callbackTypes.FINISHED, steps: nextSteps, isTourSkipped: nextState.isTourSkipped }); } } componentDidUpdate(prevProps, prevState) { const { index, shouldRedraw, isRunning, shouldRun, standaloneData } = this.state; const { scrollToFirstStep, scrollToSteps, steps } = this.props; const step = steps[index]; const scrollTop = this.getScrollTop(); const shouldScroll = ( scrollToFirstStep || (index > 0 || prevState.index > index)) && (step && !step.isFixed); // fixed steps don't need to scroll if (shouldRedraw && step) { this.calcPlacement(); } if (isRunning && scrollToSteps && shouldScroll && scrollTop >= 0) { scroll.top(getRootEl(), this.getScrollTop()); } if (steps.length && (!isRunning && shouldRun && !standaloneData)) { this.start(); } } componentWillUnmount() { window.removeEventListener('resize', this.listeners.resize); /* istanbul ignore else */ if (this.listeners.keyboard) { document.body.removeEventListener('keydown', this.listeners.keyboard); } /* istanbul ignore else */ if (Object.keys(this.listeners.tooltips).length) { Object.keys(this.listeners.tooltips) .map(key => ({ el: document.querySelector(key), event: this.listeners.tooltips[key].event, cb: this.listeners.tooltips[key].cb, key })) .filter(({ el }) => !!el) .forEach(({ el, event, cb, key }) => { el.removeEventListener(event, cb); delete this.listeners.tooltips[key]; }); } } /** * Starts the tour * * @private * * @param {boolean} [autorun] - Starts with the first tooltip opened * @param {Array} [steps] - Array of steps, defaults to this.props.steps * @param {number} [startIndex] - Optional step index to start joyride at */ start(autorun, steps = this.props.steps, startIndex = this.state.index) { const hasMountedTarget = Boolean(this.getStepTargetElement(steps[startIndex])); const shouldRenderTooltip = (autorun === true) && hasMountedTarget; logger({ type: 'joyride:start', msg: ['autorun:', autorun === true], debug: this.props.debug, }); this.setState({ action: 'start', index: startIndex, isRunning: Boolean(steps.length) && hasMountedTarget, shouldRenderTooltip, shouldRun: !steps.length, }); } /** * Stop the tour * * @private */ stop() { logger({ type: 'joyride:stop', debug: this.props.debug, }); this.setState({ isRunning: false, shouldRenderTooltip: false }); } /** * Move to the next step, if there is one. If there is no next step, hide the tooltip. */ next() { const { index, shouldRenderTooltip } = this.state; const { steps } = this.props; const nextIndex = index + 1; const shouldDisplay = Boolean(steps[nextIndex]) && shouldRenderTooltip; logger({ type: 'joyride:next', msg: ['new index:', nextIndex], debug: this.props.debug, }); this.toggleTooltip({ show: shouldDisplay, index: nextIndex, action: 'next' }); } /** * Move to the previous step, if there is one. If there is no previous step, hide the tooltip. */ back() { const { index, shouldRenderTooltip } = this.state; const { steps } = this.props; const previousIndex = index - 1; const shouldDisplay = Boolean(steps[previousIndex]) && shouldRenderTooltip; logger({ type: 'joyride:back', msg: ['new index:', previousIndex], debug: this.props.debug, }); this.toggleTooltip({ show: shouldDisplay, index: previousIndex, action: 'next' }); } /** * Reset Tour * * @param {boolean} [restart] - Starts the new tour right away */ reset(restart) { const { index, isRunning } = this.state; const shouldRestart = restart === true; const newState = { ...defaultState, isRunning: shouldRestart, shouldRenderTooltip: this.props.autoStart, }; logger({ type: 'joyride:reset', msg: ['restart:', shouldRestart], debug: this.props.debug, }); // Force a re-render if necessary if (shouldRestart && isRunning === shouldRestart && index === 0) { this.forceUpdate(); } this.setState(newState); } /** * Retrieve the current progress of your tour * * @returns {{index: number, percentageComplete: number, step: (object|null)}} */ getProgress() { const { index } = this.state; const { steps } = this.props; logger({ type: 'joyride:getProgress', msg: ['steps:', steps], debug: this.props.debug, }); return { index, percentageComplete: parseFloat(((index / steps.length) * 100).toFixed(2).replace('.00', '')), step: steps[index] }; } /** * Add standalone tooltip events * * @param {Object} data - Similar shape to a 'step', but for a single tooltip */ addTooltip(data) { if (!this.checkStepValidity(data)) { logger({ type: 'joyride:addTooltip:FAIL', msg: ['data:', data], debug: this.props.debug, }); return; } logger({ type: 'joyride:addTooltip', msg: ['data:', data], debug: this.props.debug, }); const key = data.trigger || sanitizeSelector(data.selector); const el = document.querySelector(key); if (!el) { return; } el.setAttribute('data-tooltip', JSON.stringify(data)); const eventType = data.event || 'click'; /* istanbul ignore else */ if (eventType === 'hover') { this.listeners.tooltips[`${key}mouseenter`] = { event: 'mouseenter', cb: this.onClickStandaloneTrigger }; this.listeners.tooltips[`${key}mouseleave`] = { event: 'mouseleave', cb: this.onClickStandaloneTrigger }; el.addEventListener('mouseenter', this.listeners.tooltips[`${key}mouseenter`].cb); el.addEventListener('mouseleave', this.listeners.tooltips[`${key}mouseleave`].cb); } this.listeners.tooltips[`${key}click`] = { event: 'click', cb: this.onClickStandaloneTrigger }; el.addEventListener('click', this.listeners.tooltips[`${key}click`].cb); } /** * Parse the incoming steps * * @deprecated * * @param {Array|Object} steps * @returns {Array} */ parseSteps(steps) { console.warn('joyride.parseSteps() is deprecated. It is no longer necessary to parse steps before providing them to Joyride'); //eslint-disable-line no-console return steps; } /** * Verify that a step is valid * * @param {Object} step - A step object * @returns {boolean} - True if the step is valid, false otherwise */ checkStepValidity(step) { // Check that the step is the proper type if (!step || typeof step !== 'object' || Array.isArray(step)) { logger({ type: 'joyride:checkStepValidity', msg: 'Did not provide a step object.', warn: true, debug: this.props.debug, }); return false; } // Check that all required step fields are present const requiredFields = ['selector']; const hasRequiredField = (requiredField) => { const hasField = Boolean(step[requiredField]); if (!hasField) { logger({ type: 'joyride:checkStepValidity', msg: [`Provided a step without the required ${requiredField} property.`, 'Step:', step], warn: true, debug: this.props.debug, }); } return hasField; }; return requiredFields.every(hasRequiredField); } /** * Check one or more steps are valid * * @param {Object|Array} steps - A step object or array of step objects * @returns {boolean} - True if one or more stpes, and all steps are valid, false otherwise */ checkStepsValidity(steps) { /* istanbul ignore else */ if (!Array.isArray(steps) && typeof steps === 'object') { return this.checkStepValidity(steps); } else if (steps.length > 0) { return steps.every(this.checkStepValidity); } return false; } /** * Find and return the targeted DOM element based on a step's 'selector'. * * @private * @param {Object} step - A step object * @returns {Element} - A DOM element (if found) */ getStepTargetElement(step) { const isValidStep = this.checkStepValidity(step); if (!isValidStep) { return null; } const el = document.querySelector(sanitizeSelector(step.selector)); if (!el) { logger({ type: 'joyride:getStepTargetElement', msg: 'Target not rendered. For best results only add steps after they are mounted.', warn: true, debug: this.props.debug, }); return null; } return el; } /** * Get an element actual dimensions with margin * * @private * @returns {{height: number, width: number}} */ getElementDimensions() { const { shouldRenderTooltip, standaloneData } = this.state; const displayTooltip = standaloneData ? true : shouldRenderTooltip; const el = document.querySelector(displayTooltip ? '.joyride-tooltip' : '.joyride-beacon'); let height = 0; let width = 0; if (el) { const styles = window.getComputedStyle(el); height = el.clientHeight + parseInt(styles.marginTop || 0, 10) + parseInt(styles.marginBottom || 0, 10); width = el.clientWidth + parseInt(styles.marginLeft || 0, 10) + parseInt(styles.marginRight || 0, 10); } return { height, width }; } /** * Get the scrollTop position * * @private * @returns {number} */ getScrollTop() { const { index, yPos } = this.state; const { offsetParentSelector, scrollOffset, steps } = this.props; const step = steps[index]; const target = this.getStepTargetElement(step); const offsetParent = document.querySelector(sanitizeSelector(offsetParentSelector)); if (!target) { return 0; } const rect = getBoundingClientRectFromElement(target); const targetTop = rect.top + (window.pageYOffset || document.documentElement.scrollTop); const position = this.calcPosition(step); let scrollTo = 0; /* istanbul ignore else */ if (/^top/.test(position)) { scrollTo = Math.floor(yPos - scrollOffset); } else if (/^bottom|^left|^right/.test(position)) { scrollTo = Math.floor(targetTop - scrollOffset); } return scrollTo; } /** * Trigger the callback. * * @private * @param {Object} options */ triggerCallback(options) { const { callback } = this.props; /* istanbul ignore else */ if (typeof callback === 'function') { logger({ type: 'joyride:triggerCallback', msg: [options], debug: this.props.debug, }); callback(options); } } /** * Tooltip target element resize callback. * * @private */ targetResizeCallback() { this.forceUpdate(); } /** * Keydown event listener * * @private * @param {Event} e - Keyboard event */ onKeyboardNavigation(e) { const { index, shouldRenderTooltip } = this.state; const { steps } = this.props; const intKey = (window.Event) ? e.which : e.keyCode; let hasSteps; if (shouldRenderTooltip) { if ([32, 38, 40].indexOf(intKey) > -1) { e.preventDefault(); } if (intKey === 27) { this.toggleTooltip({ show: false, index: index + 1, action: 'esc' }); } else if ([13, 32].indexOf(intKey) > -1) { hasSteps = Boolean(steps[index + 1]); this.toggleTooltip({ show: hasSteps, index: index + 1, action: 'next' }); } } } /** * Tooltip event listener * * @private * @param {Event} e - Click event */ onClickStandaloneTrigger(e) { e.preventDefault(); const { isRunning, standaloneData } = this.state; let tooltipData = e.currentTarget.dataset.tooltip; if (['mouseenter', 'mouseleave'].includes(e.type) && hasTouch) { return; } /* istanbul ignore else */ if (tooltipData) { tooltipData = JSON.parse(tooltipData); if (!standaloneData || (standaloneData.selector !== tooltipData.selector)) { this.setState({ isRunning: false, shouldRenderTooltip: false, shouldRun: isRunning, standaloneData: tooltipData, xPos: -1000, yPos: -1000 }); } else { document.querySelector('.joyride-tooltip__close').click(); } } } /** * Beacon click event listener * * @private * @param {Event} e - Click event */ onClickBeacon(e) { e.preventDefault(); const { index } = this.state; const { steps } = this.props; this.triggerCallback({ action: e.type, index, type: callbackTypes.BEACON_TRIGGER, step: steps[index] }); this.toggleTooltip({ show: true, index, action: `beacon:${e.type}` }); } /** * Tooltip click event listener * * @private * @param {Event} e - Click event */ onClickTooltip(e) { const { index, shouldRun } = this.state; const { steps, type } = this.props; const el = e.currentTarget.className.includes('joyride-') && [ 'A', 'BUTTON' ].includes(e.currentTarget.tagName) ? e.currentTarget : e.target; const dataType = el.dataset.type; /* istanbul ignore else */ if (el.className.indexOf('joyride-') === 0) { e.preventDefault(); e.stopPropagation(); const tooltip = document.querySelector('.joyride-tooltip'); let newIndex = index + (dataType === 'back' ? -1 : 1); if (dataType === 'skip') { this.setState({ isTourSkipped: true }); newIndex = steps.length + 1; } /* istanbul ignore else */ if (tooltip.classList.contains('joyride-tooltip--standalone')) { this.setState({ isRunning: shouldRun, shouldRedraw: true, shouldRun: false, standaloneData: false }); } else if (dataType) { const shouldDisplay = ['continuous', 'guided'].indexOf(type) > -1 && ['close', 'skip'].indexOf(dataType) === -1 && Boolean(steps[newIndex]); this.toggleTooltip({ show: shouldDisplay, index: newIndex, action: dataType }); } if (e.target.className === 'joyride-overlay') { this.triggerCallback({ action: 'click', type: callbackTypes.OVERLAY, step: steps[index] }); } if (e.target.classList.contains('joyride-hole')) { this.triggerCallback({ action: 'click', type: callbackTypes.HOLE, step: steps[index] }); } } } onRenderTooltip() { this.calcPlacement(); } /** * Toggle Tooltip's visibility * * @private * @param {Object} options - Immediately destructured argument object * @param {Boolean} options.show - Render the tooltip or the beacon * @param {Number} options.index - The tour's new index * @param {string} [options.action] - The action being undertaken. * @param {Array} [options.steps] - The array of step objects that is going to be rendered */ toggleTooltip({ show, index = this.state.index, action, steps = this.props.steps }) { const nextStep = steps[index]; const hasMountedTarget = Boolean(this.getStepTargetElement(nextStep)); this.setState({ action, index, // Stop playing if there is no next step or can't find the target isRunning: (nextStep && hasMountedTarget) ? this.state.isRunning : false, // If we are not showing now, or there is no target, we'll need to redraw eventually shouldRedraw: !show || !hasMountedTarget, shouldRenderTooltip: show && hasMountedTarget, xPos: -1000, yPos: -1000 }); } /** * Position absolute elements next to its target * * @private */ calcPlacement() { const { index, isRunning, standaloneData, shouldRenderTooltip } = this.state; const { offsetParentSelector, steps, tooltipOffset } = this.props; const step = standaloneData || (steps[index] || {}); const displayTooltip = standaloneData ? true : shouldRenderTooltip; const target = this.getStepTargetElement(step); const offsetParent = document.querySelector(sanitizeSelector(offsetParentSelector)); logger({ type: `joyride:calcPlacement${this.getRenderStage()}`, msg: ['step:', step], debug: this.props.debug, }); /* istanbul ignore else */ if (!target) { return; } const placement = { x: -1000, y: -1000 }; /* istanbul ignore else */ if (step && (standaloneData || (isRunning && steps[index]))) { const offsetX = nested.get(step, 'style.beacon.offsetX') || 0; const offsetY = nested.get(step, 'style.beacon.offsetY') || 0; const position = this.calcPosition(step); const body = getBoundingClientRectFromElement(document.body); const scrollTop = step.isFixed === true ? 0 : body.top; const component = this.getElementDimensions(); const rect = getBoundingClientRectFromElement(target); // Calculate x position if (/^left/.test(position)) { placement.x = rect.left - (displayTooltip ? component.width + tooltipOffset : (component.width / 2) + offsetX); } else if (/^right/.test(position)) { placement.x = (rect.left + rect.width) - (displayTooltip ? -tooltipOffset : (component.width / 2) - offsetX); } else { placement.x = rect.left + ((rect.width / 2) - (component.width / 2)); } // Calculate y position if (/^top/.test(position)) { placement.y = (rect.top - scrollTop) - (displayTooltip ? component.height + tooltipOffset : (component.height / 2) + offsetY); } else if (/^bottom/.test(position)) { placement.y = (rect.top - scrollTop) + (rect.height - (displayTooltip ? -tooltipOffset : (component.height / 2) - offsetY)); } else { placement.y = (rect.top - scrollTop); } /* istanbul ignore else */ if (/^bottom|^top/.test(position)) { if (/left/.test(position)) { placement.x = rect.left - (displayTooltip ? tooltipOffset : component.width / 2); } else if (/right/.test(position)) { placement.x = rect.left + (rect.width - (displayTooltip ? component.width - tooltipOffset : component.width / 2)); } } this.setState({ shouldRedraw: false, xPos: this.preventWindowOverflow(Math.ceil(placement.x), 'x', component.width, component.height), yPos: this.preventWindowOverflow(Math.ceil(placement.y), 'y', component.width, component.height) }); } } /** * Update position for overflowing elements. * * @private * @param {Object} step * * @returns {string} */ calcPosition(step) { const { offsetParentSelector, tooltipOffset } = this.props; const body = document.body; const target = this.getStepTargetElement(step); const offsetParent = document.querySelector(sanitizeSelector(offsetParentSelector)); const rect = getBoundingClientRectFromElement(target); const { height, width = DEFAULTS.minWidth } = this.getElementDimensions(); let position = step.position || DEFAULTS.position; if (/^left/.test(position) && rect.left - (width + tooltipOffset) < 0) { position = 'top'; } else if (/^right/.test(position) && (rect.left + rect.width + (width + tooltipOffset)) > getBoundingClientRectFromElement(body).width) { position = 'bottom'; } if (/^top/.test(position) && (rect.top + body.scrollTop) - (height + tooltipOffset) < 0) { position = 'bottom'; } else if (/^bottom/.test(position) && (rect.bottom + body.scrollTop) + (height + tooltipOffset) > getDocHeight()) { position = 'top'; } return position; } /** * Get the render stage. * * @private * @returns {string} */ getRenderStage() { const { shouldRedraw, xPos } = this.state; if (shouldRedraw) { return ':redraw'; } else if (xPos < 0) { return ':pre-render'; } return ''; } /** * Prevent tooltip to render outside the window * * @private * @param {Number} value - The axis position * @param {String} axis - The Axis X or Y * @param {Number} elWidth - The target element width * @param {Number} elHeight - The target element height * @returns {Number} */ preventWindowOverflow(value, axis, elWidth, elHeight) { const winWidth = window.innerWidth; const docHeight = getDocHeight(); let newValue = value; /* istanbul ignore else */ if (axis === 'x') { if (value + elWidth >= winWidth) { newValue = winWidth - elWidth - 15; } else if (value < 15) { newValue = 15; } } else if (axis === 'y') { if (value + elHeight >= docHeight) { newValue = docHeight - elHeight - 15; } else if (value < 15) { newValue = 15; } } return newValue; } /** * Create a React Element * * @private * @returns {boolean|ReactComponent} */ createComponent() { const { index, shouldRedraw, shouldRenderTooltip, standaloneData, xPos, yPos } = this.state; const { autoResizeHole, disableOverlay, holePadding, locale, offsetParentSelector, showBackButton, showOverlay, showSkipButton, showStepsProgress, steps, type } = this.props; const currentStep = standaloneData || steps[index]; const step = { ...currentStep }; const target = this.getStepTargetElement(step); let component; const allowClicksThruHole = (step && step.allowClicksThruHole) || this.props.allowClicksThruHole; const shouldShowOverlay = standaloneData ? false : showOverlay; const buttons = { primary: locale.close }; logger({ type: `joyride:createComponent${this.getRenderStage()}`, msg: [ 'component:', shouldRenderTooltip || standaloneData ? 'Tooltip' : 'Beacon', 'animate:', xPos > -1 && !shouldRedraw, 'step:', step ], debug: this.props.debug, warn: !target, }); if (!target) { return false; } if (shouldRenderTooltip || standaloneData) { const position = this.calcPosition(step); /* istanbul ignore else */ if (!standaloneData) { /* istanbul ignore else */ if (['continuous', 'guided'].indexOf(type) > -1) { buttons.primary = locale.last; /* istanbul ignore else */ if (steps[index + 1]) { if (showStepsProgress) { let next = locale.next; if (typeof locale.next === 'string') { next = (<span>{locale.next}</span>); } buttons.primary = (<span>{next} <span>{`${(index + 1)}/${steps.length}`}</span></span>); } else { buttons.primary = locale.next; } } if (showBackButton && index > 0) { buttons.secondary = locale.back; } } if (showSkipButton) { buttons.skip = locale.skip; } } // if autoResizeHole is enabled and target element exists, attach resize senzor if (step.autoResizeHole === true || (step.autoResizeHole !== true && autoResizeHole)) { if (resizeSensorTargetElem === null) { ResizeSensor(target, this.targetResizeCallback); resizeSensorTargetElem = target; } } component = React.createElement(Tooltip, { allowClicksThruHole, animate: xPos > -1 && !shouldRedraw, buttons, disableOverlay, holePadding, offsetParentSelector, position, selector: sanitizeSelector(step.selector), showOverlay: shouldShowOverlay, step, standalone: Boolean(standaloneData), target, type, xPos, yPos, onClick: this.onClickTooltip, onRender: this.onRenderTooltip }); } else { component = React.createElement(Beacon, { step, xPos, yPos, onTrigger: this.onClickBeacon, eventType: step.type || 'click' }); } return component; } render() { const { index, isRunning, standaloneData } = this.state; const { steps } = this.props; const hasStep = Boolean(steps[index]); let component; let standaloneComponent; if (isRunning && hasStep) { logger({ type: `joyride:render${this.getRenderStage()}`, msg: ['step:', steps[index]], debug: this.props.debug, }); } else if (!isRunning && standaloneData) { logger({ type: 'joyride:render', msg: ['tooltip:', standaloneData], debug: this.props.debug, }); } if (standaloneData) { standaloneComponent = this.createComponent(); } else if (isRunning && hasStep) { component = this.createComponent(); } return ( <div className="joyride"> {component} {standaloneComponent} </div> ); } } export default Joyride;