rm-tooltip
Version:
This is a tooltip library designed to perfectly position tooltips inside of elements with relative or absolute positioning.
620 lines (523 loc) • 19.1 kB
JavaScript
'use strict'
/**
* This is a simple tooltip class for determining the placement of a tooltip within a container.
* By calling the constructor with the new operator, the developer will get a tooltip whose center
* is placed on the center of the HTMLElement described in the "element" parameter.
*
* From there, the developer can decide to autoplace the tooltip, or use the below, above, left, or right
* methods (in combination or stand-alone) to place the tooltip relative to the container.
*
* Note that this class doesn't work particularly well with fixed positioned tooltips. Fortunately, it is extremely
* easy to position fixed tooltips and you can do so by calling tooltip.$tooltip.css({top: val, left: val})
*
* Also, the container the tooltip is being appended to must be position: relative
*
* @param {HTMLElement} element
* @param {HTMLElement} container
* @param {string|HTMLElement} tooltip
* @constructor
*/
type ViewportDimension = {
left: number,
top: number,
right: number,
bottom: number,
width: number,
height: number
}
type Coordinate = {
top : number,
left : number
}
export default class Tooltip {
element : HTMLElement
container : HTMLElement
tooltip : HTMLElement
element_rect : ClientRect
container_rect : ClientRect
container_dimension : ViewportDimension
tooltip_dimension : ViewportDimension
element_height : number
element_width : number
tooltip_height : number
tooltip_width : number
centered_coordinate : ?Coordinate
constructor(element : HTMLElement, container : HTMLElement, tooltip : HTMLElement|string){
if(!(tooltip instanceof Element) && typeof tooltip !== "string") {
throw new TypeError("The tooltip passed to the constructor must be either an html string or an instance of HTMLElement")
}
/**
* The element we're sticking the tooltip to
*
* @type {HTMLElement}
*/
this.element = element
/**
* The container the element gets appended to - important for things like scrolling and z-index
*
* @type {HTMLElement}
*/
this.container = container
/**
* The jQuery object that function as our tooltip
*
* @type {*}
*/
this.tooltip = tooltip instanceof Element ? tooltip : parseHtml(tooltip).firstChild
/**
* The ClientRect object associated with this.element
*
* @type {ClientRect}
*/
this.element_rect = this.element.getBoundingClientRect()
/**
* The ClientRect object associated with this.container
*
* @type {ClientRect}
*/
this.container_rect = this.container.getBoundingClientRect()
/**
* Positioning information for the element relative to the viewport AND ITS CONTAINER
*
* @type {{left: number, top: number, right: number, bottom: number, width: Number, height: Number}}
*/
this.container_dimension = this.calculateViewportPosition()
/**
* Storage for the tooltip's dimensions relative to the container and viewport
*
* @type {{left: number, top: number, right: number, bottom: number, width: Number, height: Number}}
*/
this.tooltip_dimension = {}
/**
* Height of this.element
* @type {number}
*/
this.element_height = this.element.offsetHeight
/**
* Width of this.element
* @type {number}
*/
this.element_width = this.element.offsetWidth
/**
* Height of the tooltip (TBD on init)
* @type {number}
*/
this.tooltip_height = 0
/**
* Width of the tooltip (TBD on init)
* @type {number}
*/
this.tooltip_width = 0
/**
*
* @type {undefined}
*/
this.centered_coordinate = undefined
this.last_coordinate = undefined
//Place the tooltip smack dab in the middle of this.element
this.placeTooltip()
}
/**
* Places the tooltip so that the tooltip's center is exactly on this.element's
* center - this is the first step prior to calling additional methods for placement
* and is called when the object is constructed
*/
placeTooltip (){
this.container.appendChild(this.tooltip)
this.tooltip_height = this.tooltip.offsetHeight
this.tooltip_width = this.tooltip.offsetWidth
//First, position the tooltip to be exactly centered over the element
this.centered_coordinate = this.getCenteredStyles()
this._applyPosition(this.centered_coordinate)
}
_applyPosition (coordinate : Coordinate) : (class_names : Array<string>|string) => void {
coordinate = this._composeCoordinates(coordinate)
this.tooltip.style.top = `${coordinate.top}px`
this.tooltip.style.left = `${coordinate.left}px`
return (class_names) => {
if(class_names) {
Array.isArray(class_names)
? this.tooltip.classList.add(...class_names)
: this.tooltip.classList.add(class_names)
}
}
}
_composeCoordinates (coordinate : Coordinate, previous : Coordinate = this.last_coordinate) {
if(previous) {
return this.last_coordinate = {
left: coordinate.left === this.centered_coordinate.left ? previous.left : coordinate.left,
top : coordinate.top === this.centered_coordinate.top ? previous.top : coordinate.top
}
}
return this.last_coordinate = coordinate
}
/**
* Figure out what the top and left properties look like if we want the tooltip
* to be dead center of the element we're sticking it to.
*
* @returns {Coordinate}
*/
getCenteredStyles () : Coordinate {
return {
top : this.container_dimension.top - (this.tooltip_height / 2) + (this.element_rect.height / 2),
left: this.container_dimension.left - (this.tooltip_width / 2) + (this.element_rect.width / 2)
}
}
/**
* Tries to position the tooltip automatically based on the element's position
* relative to the viewport
*
* @param {Number} left_cushion
* @param {Number} top_cushion
* @returns {Tooltip}
*/
autoPlace (left_cushion : number = 0, top_cushion : number = 0){
const auto_offsets = this.determineOffsetFromElement()
const left_coordinate = this._autoPlaceHorizontallyStyles( auto_offsets, left_cushion )
const top_coordinate = this._autoplaceVerticallyStyles( auto_offsets, top_cushion )
const coordinate = this._composeCoordinates(left_coordinate, top_coordinate)
this._applyPosition( coordinate )( [ auto_offsets.vertical, auto_offsets.horizontal, "autoplace" ] )
return this
}
/**
* Autoplaces the tooltip to the left or right
* @param cushion
* @returns {Tooltip}
*/
autoPlaceHorizontally (cushion : number = 0) : Tooltip {
const auto_offsets = this.determineOffsetFromElement()
const coordinate = this._autoPlaceHorizontallyStyles(auto_offsets, cushion)
this._applyPosition(coordinate)(auto_offsets.horizontal)
return this
}
_autoPlaceHorizontallyStyles (offsets, cushion : number = 0) : Coordinate {
const is_left = offsets.horizontal === 'TooltipLeft'
const movement = (this.element_width / 2) + (this.tooltip_width / 2) - cushion
return {
left : is_left ? this.centered_coordinate.left + movement : this.centered_coordinate.left - movement,
top : this.centered_coordinate.top
}
}
/**
* Autoplaces the tooltip above or below
*
* @param cushion
* @returns {Tooltip}
*/
autoPlaceVertically (cushion : number = 0) : Tooltip {
const auto_offsets = this.determineOffsetFromElement()
const coordinate = this._autoplaceVerticallyStyles(auto_offsets, cushion)
this._applyPosition(coordinate)(auto_offsets.vertical)
return this
}
_autoplaceVerticallyStyles (offsets, cushion : number = 0) : Coordinate {
const is_top = offsets.vertical === "TooltipAbove"
const movement = (this.element_height / 2) + (this.tooltip_height / 2) + cushion
return {
top : is_top ? this.centered_coordinate.top - movement : this.centered_coordinate.top + movement,
left: this.centered_coordinate.left
}
}
/**
* Places the tooltip above the element
*
* @param {Number} cushion
* @returns {Tooltip}
*/
above (cushion){
const coordinate = this._aboveStyles(cushion)
this._applyPosition(coordinate)('TooltipAbove')
return this
}
_aboveStyles (cushion : number = 0) : Coordinate {
return {
top : this.centered_coordinate.top - ((this.element_height/ 2) + (this.tooltip_height / 2) + cushion),
left: this.centered_coordinate.left
}
}
/**
* Places the tooltip below the element
*
* @param {Number} cushion
* @returns {Tooltip}
*/
below (cushion: number = 0) : Tooltip {
const coordinate = this._belowStyles(cushion)
this._applyPosition(coordinate)('TooltipBelow')
return this
}
_belowStyles (cushion : number = 0) : Coordinate {
return {
top : this.centered_coordinate.top + ((this.element_height/ 2) + (this.tooltip_height / 2) + cushion),
left: this.centered_coordinate.left
}
}
/**
* Places the tooltip to the left of the element
*
* @param {Number} cushion
* @returns {Tooltip}
*/
left (cushion : number = 0){
const coordinate = this._leftStyles(cushion)
this._applyPosition(coordinate)('TooltipLeft')
return this
}
_leftStyles (cushion : number = 0) : Coordinate{
return {
top : this.centered_coordinate.top,
left : this.centered_coordinate.left - ((this.element_width / 2) + (this.tooltip_width / 2) + cushion)
}
}
/**
* Places the tooltip to the right of the element
*
* @param {Number} cushion
* @returns {Tooltip}
*/
right (cushion : number = 0) : Tooltip {
const coordinate = this._rightStyles(cushion)
this._applyPosition(coordinate)('TooltipRight')
return this
}
_rightStyles (cushion : number = 0) : Coordinate {
return {
top : this.centered_coordinate.top,
left: this.centered_coordinate.left + ((this.element_width/ 2) + (this.tooltip_width / 2) + cushion)
}
}
/**
* Aligns the left side of the tooltip with the left side of the element
*
* @param {Number} cushion
* @returns {Tooltip}
*/
alignLeft (cushion : number = 0) : Tooltip {
const coordinate = this._alignLeftStyles(cushion)
this._applyPosition(coordinate)('TooltipAlignLeft')
return this
}
_alignLeftStyles (cushion : number = 0) : Coordinate {
const difference = (this.element_width - this.tooltip_width) / 2
return {
top : this.centered_coordinate.top,
left: this.centered_coordinate.left - difference - cushion,
}
}
/**
* Aligns the right side of the tooltip with the right side of the element
*
* @param {Number} cushion
* @returns {Tooltip}
*/
alignRight (cushion : number = 0) : Tooltip {
const coordinate = this._alignRightStyles(cushion)
this._applyPosition(coordinate)('TooltipAlignRight')
return this
}
_alignRightStyles (cushion : number = 0) : Coordinate {
const difference = (this.element_width - this.tooltip_width) / 2
return {
top : this.centered_coordinate.top,
left: this.centered_coordinate.left + difference + cushion,
}
}
/**
* This is a variadic function that accepts the following string arguments:
* "top", "bottom", "left", "right". The difference with this function is
* that it places the markup inside of this.element rather than on the outside
* like most tooltips.
*
* @returns {Tooltip}
*/
inside (){
if(arguments.length){
const args = Array.prototype.slice.call(arguments)
args.forEach((arg) => {
switch (arg){
case "top":
this._insideTop()
break
case "bottom":
this._insideBottom()
break
case "left":
this.alignLeft()
break
case "right":
this.alignRight()
break
default:
break
}
})
}
return this
}
/**
* Places the tooltip's top property on an equal plane to the element's top
* value (in an absolute sense)
*
* @returns {Tooltip}
* @private
*/
_insideTop (){
this._applyPosition({
...this.centered_coordinate,
top : this.centered_coordinate.top - this.centered_coordinate.top / 4
})
return this
}
/**
* Places the tooltip on the bottom of the element
*
* @returns {Tooltip}
* @private
*/
_insideBottom (){
this._applyPosition({
...this.centered_coordinate,
top : this.centered_coordinate.top + this.centered_coordinate.top / 2
})
return this
}
/**
* Re-centers a tooltip that has already been placed on the DOM. The idea is that you could do something like:
*
* const tooltip = new Tooltip(args)
* tooltip.above()
* //something else happens
* tooltip.center().below()
*
* @returns {Tooltip}
*/
center (){
//Remove any classes that have been applied by the class
this._removeClasses()
//Recalculate viewport positions
this.element_rect = this.element.getBoundingClientRect()
this.container_rect = this.container.getBoundingClientRect()
this.container_dimension = this.calculateViewportPosition()
this.tooltip_height = this.tooltip.offsetHeight
this.tooltip_width = this.tooltip.offsetWidth
//Next apply the centered styles to the tooltip
this.centered_coordinate = this.getCenteredStyles()
this.tooltip.style.top = `${this.centered_coordinate.top}px`
this.tooltip.style.left = `${this.centered_coordinate.left}px`
return this
}
/**
*
* @returns {Tooltip}
* @private
*/
_removeClasses (){
this.tooltip.classList.remove(...['TooltipAlignRight', 'TooltipAlignLeft', 'TooltipRight', 'TooltipLeft', ',TooltipAbove', 'TooltipBelow'])
return this
}
/**
* Removes the tooltip
* @returns {Tooltip}
*/
destroy (){
document.body.removeEventListener('click', this.destroy)
if(this.tooltip.parentNode) {
this.tooltip.parentNode.removeChild(this.tooltip)
}
return this
}
/**
*
* @returns {Tooltip}
*/
hide (){
this.tooltip.style.display = "none"
return this
}
/**
*
* @returns {Tooltip}
*/
show () {
this.tooltip.style.display = "block"
return this
}
/**
*
* @returns {Tooltip}
*/
removeListener (){
setTimeout(() => {
document.body.addEventListener('click', this.destroy.bind(this))
}, 50)
return this
}
/**
*
* @param {Event} event
* @param {Function} func
*/
setClickCallback (event : MouseEvent, func : Function) : Tooltip {
event.stopPropagation()
document.body.addEventListener(event, () => {
func.call(this, event.target, this.tooltip)
})
return this
}
/**
* Scroll with a particular container - this method is usually discouraged,
* but for certain tricky situations it may be relevant
*/
scrollWith (container : HTMLElement) {
let last_position = container.scrollTop
container.addEventListener('scroll', () => {
const difference = last_position - container.scrollTop
this.tooltip.style.top = this.tooltip.style.top + difference
last_position = container.scrollTop
})
return this
}
/**
* Gets the element's position relative to the viewport so that we can decide
* the best alignment for the tooltip
*
* @returns {{left: number, top: number, right: number, bottom: number, width: Number, height: Number}}
*/
calculateViewportPosition () {
const dom_rect = this.element_rect
const cont_rect = this.container_rect
const left = dom_rect.left - cont_rect.left + this.container.scrollLeft
const top = dom_rect.top - cont_rect.top + this.container.scrollTop
const right = dom_rect.right - cont_rect.right
const bottom = dom_rect.bottom - cont_rect.bottom
return {
left : left,
top : top,
right : right,
bottom : bottom,
width : dom_rect.width,
height : dom_rect.height
}
}
/**
* Determine if this tooltip should go on the right or left side, on the top or bottom of the element
* @returns {{horizontal: string, vertical: string}}
*/
determineOffsetFromElement () {
const halfWindowHeight = window.innerHeight / 2
const halfWindowWidth = window.innerWidth / 2
const horizontalClass = this.element_rect.left > halfWindowWidth ? 'TooltipRight' : 'TooltipLeft'
const verticalClass = this.element_rect.top > halfWindowHeight ? 'TooltipAbove' : 'TooltipBelow'
return {horizontal: horizontalClass, vertical : verticalClass}
}
}
const parseHtml = (html : string) : DocumentFragment => {
const fragment = document.createDocumentFragment()
const temp = document.createElement('div')
temp.innerHTML = html
let child
while (child = temp.firstElementChild) {
fragment.appendChild(child)
}
return fragment
}