import Constants from './constants';
import Style from './style';
// distance between arrow tip and edge of tooltip, not including border
const DEFAULT_ARROW_LENGTH = 11;
/** The tooltip configuration allows you to specify which anchor element will
* be pointed to by the tooltip, along with its position. A default template is
* provided, which can be configured
*
* @typedef TooltipConfiguration
* @property {string} position - Relatively positions the tooltip to the anchor
* element. Possible values: 'top' | 'left' | 'bottom' | 'right'
* @property {string} [anchorElement] - Optional if the corresponding Step
* contains only one selector. anchorElement can be either
* (1) a key from StepConfiguration.selectors above, or
* (2) a CSS selector
* @property {string} [className] - One or more space-separated classes to be
* added to the class attribute of each tooltip.
* @property {number} [xOffsetTooltip] - Value in pixels to offset the
* x-coordinate of the tooltip.
* @property {number} [yOffsetTooltip] - Value in pixels to offset the
* y-coordinate of the tooltip.
* @property {number} [offsetArrow] - Value in pixels to offset the arrow.
* If the position is top or bootom, this still offset the x coord. If
* left or right it will offset the y coord. If undefined or 0, arrow is centered.
* @property {Tooltip-renderCallback} [render] - (TODO) Renders a custom template,
* thereby ignoring all other properties below.
* @property {string} [iconUrl] - Path to an image displayed above the title.
* @property {string} [title] - The title text of a toolip.
* @property {string|function} [body] - The body text of a tooltip, or a callback
* that returns custom HTML.
* @property {string} [cta] - The text contained within the button.
* @property {Object} [attr] - HTML attributes to set on the tooltip.
* @property {Number} [arrowLength] - Distance between arrow tip and edge of
* tooltip, not including border. A value of 0 removes the arrow.
* @property {Tooltip-subtextCallback} [subtext] - Callback that returns subtext
* content.
*
*/
/**
* A function that provides step information and returns subtext content.
* @callback Tooltip-renderCallback
* @param {number} currentStep - The current step number
* @param {number} totalSteps - The total # of steps
* @returns {string} markup - The HTML markup that represents the subtext
*/
/**
* A function that provides step information and returns subtext content.
* @callback Tooltip-subtextCallback
* @param {number} currentStep - The current step number
* @param {number} totalSteps - The total # of steps
* @returns {string} markup - The HTML markup that represents the subtext
*/
class Tooltip {
/**
* @constructor
* @param {TooltipConfiguration} config - The configuration for this tooltip
* @param {Step} step - The Step object displayed along with this tooltip
* @param {Tutorial} tutorial - The Tutorial object corresponding to this
* Tooltip
*/
constructor(config, step, tutorial) {
this.config = config;
this.step = step;
this.tutorial = tutorial;
this.position = config.position;
let arrowClass = 'chariot-tooltip';
switch (this.position) {
case 'left':
arrowClass += '-arrow-right';
break;
case 'right':
arrowClass += '-arrow-left';
break;
case 'top':
arrowClass += '-arrow-bottom';
break;
case 'bottom':
arrowClass += '-arrow-top';
break;
}
this.className = config.className;
this.xOffsetTooltip = config.xOffsetTooltip ? parseInt(config.xOffsetTooltip) : 0;
this.yOffsetTooltip = config.yOffsetTooltip ? parseInt(config.yOffsetTooltip) : 0;
this.offsetArrow = config.offsetArrow ? parseInt(config.offsetArrow) : 0;
this.arrowClass = arrowClass;
this.width = parseInt(config.width);
this.height = parseInt(config.height);
let selectorKeys = Object.keys(this.step.selectors);
if (selectorKeys.length > 1 && !config.anchorElement) {
throw new Error('anchorElement is not optional when more than one ' +
'selector exists:\n' + this);
}
this.anchorElement = config.anchorElement || selectorKeys[0];
this.text = config.text;
this.iconUrl = config.iconUrl;
this.title = config.title;
this.attr = config.attr || {};
this.arrowLength = config.arrowLength || DEFAULT_ARROW_LENGTH;
}
currentStepNum() {
return this.tutorial.stepNum(this.step);
}
render() {
let $tooltip = this.$tooltip = this._createTooltipTemplate();
$('body').append($tooltip);
let $tooltipArrow = this.$tooltipArrow = $('.chariot-tooltip-arrow');
this._position($tooltip, $tooltipArrow);
// Add event handlers
$('.chariot-btn-row button').click(() => {
this.next();
});
}
tearDown() {
if (!this.$tooltip) return;
this.$tooltip.remove();
this.$tooltip = null;
this.$tooltipArrow.remove();
this.$tooltipArrow = null;
}
reposition() {
this._position(this.$tooltip, this.$tooltipArrow);
}
toString() {
return `[Tooltip - currentStep: ${this.currentStepNum()}, Step: ${this.step},` +
` text: ${this.text}]`;
}
//// PRIVATE
_createTooltipTemplate() {
let currentStep = this.currentStepNum();
let totalSteps = this.tutorial.steps.length;
this.cta = this.config.cta || (currentStep != totalSteps ? 'Next' : 'Done');
this.subtext = this.config.subtext ||
(() => `${currentStep} of ${totalSteps}`);
let subtextMarkup = this._subtextMarkup();
let buttonFloat = subtextMarkup === '' ? 'center' : 'right';
let template = `
<div class="chariot-tooltip chariot-step-${currentStep} ${this._classNames()}">
${this._arrowMarkup()}
<div class="chariot-tooltip-content">${this._iconMarkup()}</div>
<h1 class="chariot-tooltip-header">${this.title}</h1>
<div class="chariot-tooltip-content"><p>${this.text}</p></div>
<div class="chariot-btn-row">
${subtextMarkup}
<button class="btn btn-inverse ${buttonFloat}">${this.cta}</button>
</div>
</div>`;
let $template = $(template);
// Add default data attributes
this.attr['data-step-order'] = currentStep;
$template.attr(this.attr);
return $template;
}
_classNames() {
if (!this.className) return '';
return this.className;
}
_iconMarkup() {
if (!this.iconUrl) return '';
return `<div class='chariot-tooltip-icon'>
<img class='chariot-tooltip-icon-img' src="${this.iconUrl}"/>
</div>`;
}
_subtextMarkup() {
if (!this.subtext) return '';
return `<span class='chariot-tooltip-subtext'>
${this.subtext(this.currentStepNum(), this.tutorial.steps.length)}
</span>`;
}
_arrowMarkup() {
if (this.arrowLength === 0) return '';
return `<div class="chariot-tooltip-arrow ${this.arrowClass}"></div>`;
}
_position($tooltip, $tooltipArrow) {
this._positionTooltip($tooltip);
this._positionArrow($tooltip, $tooltipArrow);
}
_positionTooltip($tooltip) {
let $anchorElement = this._getAnchorElement();
if (!$anchorElement) return;
this.borderLeftWidth = parseInt($tooltip.css('border-left-width')) || 0;
this.borderRightWidth = parseInt($tooltip.css('border-right-width')) || 0;
this.borderBottomWidth = parseInt($tooltip.css('border-bottom-width')) || 0;
this.borderTopWidth = parseInt($tooltip.css('border-top-width')) || 0;
let top = Style.calculateTop($tooltip,
$anchorElement, this.yOffsetTooltip, this.position,
this.arrowLength + this.borderTopWidth + this.borderBottomWidth
);
let left = Style.calculateLeft($tooltip,
$anchorElement, this.xOffsetTooltip, this.position,
this.arrowLength + this.borderLeftWidth + this.borderRightWidth
);
let tooltipStyles = {
top: top,
left: left,
'z-index': Constants.TOOLTIP_Z_INDEX,
position: 'absolute'
};
$tooltip.css(tooltipStyles);
}
/*
Positions the arrow to point at the center of the anchor element.
If a tooltip is offset via xOffsetTooltip / yOffsetTooltip, the arrow will continue to
point to center. You can change this via the offsetArrow property.
*/
_positionArrow($tooltip, $tooltipArrow) {
if (this.arrowLength === 0) return;
let arrowDiagonal = this.arrowLength * 2;
// Calculate length of arrow sides
// a^2 + b^2 = c^2, but a=b since arrow is a square, so a = sqrt(c^2 / 2)
let arrowEdge = Math.sqrt(Math.pow(arrowDiagonal, 2) / 2);
let arrowEdgeStyle = `${arrowEdge}px`;
let arrowStyles = {
'z-index': Constants.TOOLTIP_Z_INDEX + 1,
width: arrowEdgeStyle,
height: arrowEdgeStyle
};
let top, left, min, max, borderWidth;
let borderRadius = parseInt($tooltip.css('border-radius')) || 0;
switch (this.arrowClass) {
case 'chariot-tooltip-arrow-left':
top = (($tooltip.outerHeight() - arrowDiagonal) / 2) - this.yOffsetTooltip +
this.offsetArrow;
min = borderRadius;
max = $tooltip.outerHeight() - arrowDiagonal - borderRadius;
arrowStyles.top = Math.max(Math.min(top, max), min);
arrowStyles.left = -(arrowEdge / 2 + this.borderLeftWidth);
break;
case 'chariot-tooltip-arrow-right':
top = (($tooltip.outerHeight() - arrowDiagonal) / 2) - this.yOffsetTooltip +
this.offsetArrow;
min = borderRadius;
max = $tooltip.outerHeight() - arrowDiagonal - borderRadius;
arrowStyles.top = Math.max(Math.min(top, max), min);
arrowStyles.right = -(arrowEdge / 2 + this.borderRightWidth);
break;
case 'chariot-tooltip-arrow-bottom':
left = (($tooltip.outerWidth() - arrowDiagonal) / 2) - this.xOffsetTooltip +
this.offsetArrow;
min = borderRadius;
max = $tooltip.outerWidth() - arrowDiagonal - borderRadius;
arrowStyles.left = Math.max(Math.min(left, max), min);
arrowStyles.bottom = -(arrowEdge / 2 + this.borderBottomWidth);
break;
case 'chariot-tooltip-arrow-top':
left = (($tooltip.outerWidth() - arrowDiagonal) / 2) - this.xOffsetTooltip +
this.offsetArrow;
min = borderRadius;
max = $tooltip.outerWidth() - arrowDiagonal - borderRadius;
arrowStyles.left = Math.max(Math.min(left, max), min);
arrowStyles.top = -(arrowEdge / 2 + this.borderTopWidth);
break;
}
$tooltipArrow.css(arrowStyles);
}
_getAnchorElement() {
// Look for already cloned elements first
let clonedSelectedElement = this.step.getClonedElement(this.anchorElement);
if (clonedSelectedElement) return clonedSelectedElement;
const anchorElement = this.step.selectors[this.anchorElement];
// Try fetching from selectors
let $element = $(anchorElement);
// Try fetching from DOM
if ($element.length === 0) {
$element = $(this.anchorElement);
}
if ($element.length === 0) {
console.log("Anchor element not found: " + this.anchorElement);
}
return $element;
}
next() {
this.step.next();
}
}
export default Tooltip;