A JS Framework for Rich Text Tooltips
Published: 2014-12-29
Updated: 2015-04-04
Web: https://fritzthecat-blog.blogspot.com/2014/12/a-js-framework-for-rich-text-tooltips.html
When you look at the amount of available JavaScript tooltip libraries on the internet you would never think of implementing tooltips by yourself. A little later, when you look at the size of some of those libraries, you are beginning to doubt. And after looking at the demos and reading the introductions some questions come up:
"Wouldn't it be better to do this by myself instead of - buying features I never will use,
- fooling around with irritating APIs and undocumented parameters,
- picking up big implementations that support browsers nobody uses any more, or
- finding out too late that the library does not support things I took for granted
?"
Sometimes the effort for finding out about a library is bigger than solving the task by yourself. A consequence of missing specification and standardization of software on the internet, and the pressure of the free market to sell its products. But, excuse me, why would you need JavaScript tooltips? Don't you know the HTML "title" attribute? This provides browser-native tooltips!
The weaknesses of this kind of tooltip are:
- they are single-line
- they do not support rich text attributations like HTML does
- you can not copy text out of a tooltip
Sometimes it disappears when you start to read it :-) Surely I do not want to add another tooltip library to that internet abundance. But I want to know how much effort it is to write such from scratch. And I want to write a framework. In JavaScript. In passed Blogs I tried to show up the weaknesses of that language, now lets look if I can overcome them (missing encapsulation, poor inheritance, no function overloading, missing types, ....).
Framework
To exactly specify what I am talking about, what is a framework?
A basic structure underlying a system, concept, or text.
I like this definition. Frankenstein was the result of framework development, just because frightening people always was a quite successful literary concept !-) A framework gives us the frame for what we want to do. It will provide standard solutions and avoid beginner's mistakes. For example, with a good builder framework you could build both your house and the software you have to write to afford it, and both will be perfect :-)
The most primitive form of a framework is a super-class. You can extend that super-class and overwrite some factories and methods to adapt it to a new use-case. OO languages were created to facilitate frameworks. A framework is just the plan of something, nothing, or few, concrete.
Frameworks are quite near to configuration. Configuration is done after deployment (installation), or at run-time. Framework extensions have to be ready at compile-time. But the difference gets diffuse here as there is no compile-time for JS.
A JS Tooltip Framework?
Why would I need a framework to implement JS tooltips?
Because tooltips share logic with other components like dialogs:
- opening a window at a well visible but not obscuring location
- preventing other windows to interfere while the one is open
- making HTML page contents visible that were not visible before
Look at jBox. It provides tooltips that can also be used as dialogs. Both were built on a framework. Although I do not want to implement dialogs now, I want to keep my code open to such extensions. Specification
Basically the tooltips should provide the following (hover item 1 and 2 to see examples):
- I want to write a "rich" tip text as HTML, in the same document where its id will be referenced by the element the tip is to be shown over
- or as attribute content in the element the tip is to be shown over
- I want to copy a tip text using mouse and keyboard (selecting text with Ctl-C)
- the tip should stay as long as another element is hovered
- when I press ESCAPE or click the mouse elsewhere, the tip should disappear
- the tip should appear not immediately when the mouse is over a tipped element, but after a configurable delay time
- no need to support browsers that do not conform to standards, so no jQuery will be involved
And I want to achieve different kinds of tooltips, either by configuration or by frameworking (hover item 1 and 2 to see examples): - fixed size tips, not being sized by the browser
- custom styled tips, e.g. another background color, or rounded corners
- tips with programmatically determined text content, showing e.g. the HTML tag-name of the element
<p data-tooltip-idref="tip-1">
Hover me to see the tooltip referenced by data-tooltip-idref.
</p>
<div id="tip-1" style="display: none;">
I am the tooltip for example above.
</div>
<p data-tooltip="I am the tooltip for this element.">
Hover me to see the tooltip referenced by data-tooltip.
</p>
<!--
tooltip programming example:
fixed size
-->
<script type="text/javascript">
window.addEventListener("load", function() {
var manager = tooltipManager();
var tooltip = manager.install();
tooltip.style["width"] = "12em";
tooltip.style["height"] = "5em";
});
</script>
<!--
tooltip programming example:
background color
-->
<script type="text/javascript">
window.addEventListener("load", function() {
var manager = tooltipManager();
var tooltip = manager.install();
tooltip.style.background = "#EEBB66";
tooltip.style.cssText += "border-radius: 10px;";
});
</script>
With these user stories in mind I began to implement what you might already have seen now when you hovered the list items above. As this Blog does not allow me to import the script source from another server, I've pasted it completely here (~ 400 lines of code). You can view it by browser menu item "Page Source", or try pressing Ctl-U. You find a documented and current version of that source on my demo page.
Implementation
The idea of an HTML tooltip is an initially hidden (CSS "display: none;"
) element that is made visible by listening to mouse-move and mouse-enter/leave events. A recursive mouse listener installation on all elements having tooltips should provide the event. That event, together with the location of the element receiving it, determines where the tip will be shown (CSS "position: absolute; left: ...px; top: ...px;"
).
There are different types of mouse events one can receive through an element.addEventListener()
installation, and which of them and when they arrive is a little browser-specific. On the jQuery "mouseenter" page you can try that out. Thus an important capacity of the implementation must be to keep the installation of the mouse listeners overridable.
Here is a conceptual outline of my JS code. Hover it to read explanations. It is built on the idea of functional inheritance.
var tooltipManager = function()
{
// public overridable functions and fields
var that = {}; // return of this function
that.delay = 1000;
that.install = function(root, tooltip) {
....
}
.... // other publics
// private functions and fields
var timer = undefined;
var clearTimer = function() {
....
}
.... // other privates
return that;
};
This is what I call a factory function.
It returns a new tooltip-manager without using the new
keyword
(so we won't need to mess around with this
).
Public functions and variables are attached to the that
object.
Private functions and variables are declared using var
,
and thus are not visible outside the factory function.
Nevertheless they are bound to the instance variables, as are the publics.
Public and private functions use private functions and variables quite intuitively:
var privateFunction = function() {
privateField = ...;
privateFunction();
}
But public and private functions must call public functions and variables with the that.
prefix:
var privateFunction = function() {
that.publicField = ...;
that.publicFunction();
}
Next is the complete outline of functions and fields I needed to implement the rich text tooltips you see on this page. I've left out only the logger
variable and log()
function.
Hover the items for viewing their implementations - that way you can experience the tooltip feeling :-) Maybe the days of tooltips are numbered, because they do not exist on mobile devices ...
var tooltipManager = function()
- that.delay;
- that.install = function(root, tooltip)
- that.installMouseListeners = function(element, tooltip)
- that.newTooltip = function()
- that.buildTooltip = function()
- that.browserClientArea = function()
- that.location = function(tooltip, boundingClientRect, x, y)
- that.showDelayed = function(tooltip, element, x, y)
- that.show = function(tooltip, element, x, y)
- that.hide = function(tooltip)
- that.isShowing = function(tooltip)
- that.getTooltipContent = function(element)
- that.hasTooltip = function(element)
- that.getTooltipIdRef = function(element)
- that.getTooltipAttribute = function(element)
- var timer;
- var currentElement;
- var clearTimer = function()
- var setDisplaying = function(tooltip)
- var smartCoordinate = function(coordinate, isX, boundingClientRect, tooltipGoesTopOrLeft, scrollOffset)
- var installMouseListeners = function(element, tooltip)
- var mouseIn = function(event)
- var mouseOut = function(event)
- var installTooltipListeners = function(tooltip)
- var installElementListenersRecursive = function(element, tooltip)
Mind that private functions are not commented, but publics are documented extensively. This is because you can call them from outside, and you can override them, and doing so you must know what is expecting you. Privates on the other hand should be self-documenting in their context. I hope I've found good identifiers and names to achieve that.
Some functions are not public to be called from outside but to be overridable. There is no way to express such in JS. In Java they would be protected
non-final.
/** The delay of tooltip appearance. */
that.delay = 1000;
/**
* Creates a tooltip when not given. Installs event listeners
* recursively on either given root element or document body.
* These listeners connect the elements with the tooltip.
* One tooltip is used for all elements, replacing content dynamically.
* @param root optional, the element from which to search for
* elements having a tooltip, will be body when not given.
* @param tooltip optional, a tooltip element previously built
* by calling buildTooltip().
* @return the created tooltip, as a DOM element, to be further
* styled by caller.
*/
that.install = function(root, tooltip) {
var tip = tooltip || that.buildTooltip();
var element = root || document.body;
installElementListenersRecursive(element, tip);
return tip;
};
/**
* Installs event listeners necessary to open and close
* given tooltip on given element.
* @param tooltip the tooltip DOM node for given element.
* @param element the element having given tooltip.
*/
that.installMouseListeners = function(element, tooltip) {
installMouseListeners(element, tooltip);
};
/**
* Creates the tooltip singleton as a "div" element.
* This is a "factory method", to be overridden, not to be called.
* Call buildTooltip() to create a new tooltip element.
*/
that.newTooltip = function() {
return document.createElement("div");
};
/*
* Creates and initializes a tooltip with some default CSS settings.
* Appends the tooltip to document body, invisibly.
* Calls maxSize() and installTooltipListeners() after.
*/
that.buildTooltip = function() {
var tooltip = that.newTooltip();
document.body.appendChild(tooltip); // add to page on top-level
tooltip.style.display = "none"; // initially invisible
tooltip.style.position = "absolute"; // position from page top (not viewport top)
tooltip.style.overflow = "auto"; // show scrollbars when content too big
tooltip.style.resize = "both"; // let user resize the tip
tooltip.style.background = "white"; // else would be transparent
tooltip.style.padding = "4px"; // a little space around contents
tooltip.style.border = "1px solid gray"; // border around tooltip
// for some reason JS assignments like
// elem.style["max-width"] = 100;
// do not work when spaces or "-" are contained in CSS property name, so do it the rough way:
tooltip.style.cssText += "; box-shadow: 4px 6px 15px #888888;";
// maximum size is a quarter of the page's viewport.
var clientArea = that.browserClientArea();
var maxWidth = Math.round(clientArea.viewPortWidth / 2);
var maxHeight = Math.round(clientArea.viewPortHeight / 2);
tooltip.style.cssText += "; max-width: "+maxWidth+"px; max-height: "+maxHeight+"px;";
installTooltipListeners(tooltip);
return tooltip;
};
/**
* Delivers the current browser geometry, in pixels.
* The scroll-offset is the distance between the page border
* and the visible area of the page (being in viewport).
* The viewport is the browser's client area. It frames
* the visible area of the rendered HTML page.
* @returns Object with members scrollOffsetLeft, scrollOffsetTop,
* viewPortWidth, viewPortHeight, all in pixels.
*/
that.browserClientArea = function() {
return {
scrollOffsetLeft : window.pageXOffset || document.documentElement.scrollLeft,
scrollOffsetTop : window.pageYOffset || document.documentElement.scrollTop,
viewPortWidth : Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
viewPortHeight : Math.max(document.documentElement.clientHeight, window.innerHeight || 0)
};
};
/**
* Locates the tooltip relative to given mouse point,
* orientation always towards the middle of the page.
* Uses element.style["left"] and "top" for this.
* @param tooltip the tooltip element of the page.
* @param boundingClientRect the rectangle of the element where the mouse is
* over, relative to the viewport (not to the page). Might be undefined.
* @param x mouse point distance from left edge of page (not of viewport).
* @param y mouse point distance from top egde of page (not of viewport).
*/
that.location = function(tooltip, boundingClientRect, x, y) {
var clientArea = that.browserClientArea();
var relativeMouseX = x - clientArea.scrollOffsetLeft;
var relativeMouseY = y - clientArea.scrollOffsetTop;
var tooltipGoesLeft = (relativeMouseX > clientArea.viewPortWidth / 2); // mouse is in left half of view
var tooltipGoesTop = (relativeMouseY > clientArea.viewPortHeight / 2); // mouse is in upper half of view
if (that.logger) log("received mouse event: x="+x+", y="+y+", goes top: "+tooltipGoesTop+", goes left: "+tooltipGoesLeft+", scrollTop="+clientArea.scrollOffsetTop+", scrollLeft="+clientArea.scrollOffsetLeft);
if (boundingClientRect) { // some browsers don't provide this feature
// correct the mouse position to not hide contents
x = smartCoordinate(x, true, boundingClientRect, tooltipGoesLeft, clientArea.scrollOffsetLeft);
y = smartCoordinate(y, false, boundingClientRect, tooltipGoesTop, clientArea.scrollOffsetTop);
if (that.logger) log("smart x="+x+", y="+y+", elementRect left="+boundingClientRect.left+", right="+boundingClientRect.right+", top="+boundingClientRect.top+", bottom="+boundingClientRect.bottom);
}
if (tooltipGoesLeft) {
x -= tooltip.offsetWidth;
if (x < clientArea.scrollOffsetLeft)
x = clientArea.scrollOffsetLeft;
}
if (tooltipGoesTop) {
y -= tooltip.offsetHeight;
if (y < clientArea.scrollOffsetTop)
y = clientArea.scrollOffsetTop;
}
tooltip.style.left = x+"px"; // must be absolute
tooltip.style.top = y+"px"; // must be absolute
if (that.logger) log("tooltip top left x="+x+", y="+y+", width="+tooltip.offsetWidth+", height="+tooltip.offsetHeight);
};
/**
* Called from event listeners. Makes tooltip delayed visible
* when no new event arrives until delay time has expired.
* @param tooltip the tooltip DOM node.
* @param element the element whose tooltip is to be shown.
* @param x mouse point distance from left edge of page (not of viewport).
* @param y mouse point distance from top egde of page (not of viewport).
*/
that.showDelayed = function(tooltip, element, x, y) {
if (that.isShowing(tooltip) && currentElement === element)
return;
that.hide(tooltip); // clears also timer
var display = function() {
that.show(tooltip, element, x, y);
};
if (that.delay > 0)
timer = window.setInterval(display, that.delay);
else
display();
};
/**
* Called from delay-timer. Puts according content into tooltip, makes
* it actually visible, and locates it near given page coordinates.
* @param tooltip the (invisible) tooltip DOM node.
* @param element the element whose tooltip is to be shown.
* @param x mouse point distance from left edge of page (not of viewport).
* @param y mouse point distance from top egde of page (not of viewport).
*/
that.show = function(tooltip, element, x, y) {
clearTimer();
var tooltipContent = that.getTooltipContent(element);
if (tooltipContent)
tooltip.innerHTML = tooltipContent;
else
return; // maybe tooltip was removed dynamically
setDisplaying(tooltip); // makes it visible
currentElement = element;
if (element)
that.location(tooltip, element.getBoundingClientRect(), x, y);
};
/**
* Called from event listeners. Makes tooltip invisible immediately.
* @param tooltip the tooltip DOM node to close.
*/
that.hide = function(tooltip) {
tooltip.style["display"] = "none"; // makes it invisible
clearTimer(); // must be here for leave-window events
};
/**
* @param tooltip the tooltip DOM node.
* @return true if tooltip is visible, else false.
*/
that.isShowing = function(tooltip) {
return tooltip.style["display"] !== "none";
};
/**
* Finds a tooltip content for given element. To be overridden
* for other methods than "tooltipId" or "tooltip" element attributes.
* @param element the HTML element to find a tooltip content for.
* @return the content of the tooltip for given element,
* or undefined if no content exist.
*/
that.getTooltipContent = function(element) {
var tooltipId = that.getTooltipIdRef(element);
var tooltipText = tooltipId
? document.getElementById(tooltipId)
: that.getTooltipAttribute(element);
return tooltipId ? tooltipText.innerHTML : tooltipText;
};
/**
* Listeners are installed only on elements that have a tooltip.
* @param element the element to decide for whether it has a tooltip.
* @return true when given element has a tooltip, else false.
*/
that.hasTooltip = function(element) {
return that.getTooltipIdRef(element) || that.getTooltipAttribute(element);
};
/**
* @param element the HTML element to find a tooltip id for.
* @return the value of attribute "data-tooltip-idref", or undefined.
*/
that.getTooltipIdRef = function(element) {
return element.getAttribute("data-tooltip-idref");
};
/**
* @param element the HTML element to find a tooltip attribute for.
* @return the value of attribute "data-tooltip", or undefined.
*/
that.getTooltipAttribute = function(element) {
return element.getAttribute("data-tooltip");
};
The private timer
variable holds the timeout object while
the tooltip-manager is waiting for timeout expiration.
Needed to clear the timeout in case it is interrupted by a new mouse event.
The private currentElement
variable holds the element a tip is currently shown for.
Needed to not close and immediately open again the same tooltip.
var clearTimer = function() {
if (timer) {
window.clearInterval(timer);
timer = undefined;
}
};
var setDisplaying = function(tooltip) {
tooltip.style["display"] = "block"; // makes it visible
};
var smartCoordinate = function(coordinate, isX, boundingClientRect, tooltipGoesTopOrLeft, scrollOffset) {
// Adjusts the y-position of tooltip to bottom (or top) of the element
// in case the element is not higher than 50 pixels.
var attachToElementMaxSize = isX ? 30 : 50; // pixels
var elementSize = isX
? boundingClientRect.right - boundingClientRect.left
: boundingClientRect.bottom - boundingClientRect.top;
var attachToElementSize = Math.min(attachToElementMaxSize, elementSize);
var newCoordinate;
if (tooltipGoesTopOrLeft) // subtract one row height as long as not above element
newCoordinate = Math.max(
coordinate - attachToElementSize,
scrollOffset + (isX ? boundingClientRect.left : boundingClientRect.top) + 1);
else // add one row height as long as not below element
newCoordinate = Math.min(
coordinate + attachToElementSize,
scrollOffset + (isX ? boundingClientRect.right : boundingClientRect.bottom) - 1);
if (tooltipGoesTopOrLeft && newCoordinate < coordinate ||
! tooltipGoesTopOrLeft && newCoordinate > coordinate)
return newCoordinate;
return coordinate; // something went wrong with browser values
};
var installMouseListeners = function(element, tooltip) {
// prevent bubbling to parents, they would show their tooltips
// event might repeat (browser-specific) while mouse moves over element
var mouseIn = function(event) {
if (element === tooltip)
setDisplaying(tooltip);
else
that.showDelayed(tooltip, element, event.pageX, event.pageY);
event.stopPropagation();
};
// when mouse leaves, close tooltip
var mouseOut = function(event) {
that.hide(tooltip);
event.stopPropagation();
};
element.addEventListener("mouseenter", function(event) {
mouseIn(event);
});
element.addEventListener("mouseover", function(event) {
mouseIn(event);
});
if (element !== tooltip) {
element.addEventListener("mouseout", function(event) {
mouseOut(event);
});
element.addEventListener("mouseleave", function(event) {
mouseOut(event);
});
}
};
var mouseIn = function(event) {
if (element === tooltip)
setDisplaying(tooltip);
else
that.showDelayed(tooltip, element, event.pageX, event.pageY);
event.stopPropagation();
};
var mouseOut = function(event) {
that.hide(tooltip);
event.stopPropagation();
};
var installTooltipListeners = function(tooltip) {
installMouseListeners(tooltip, tooltip);
// close on ESCAPE
document.documentElement.addEventListener("keydown", function(event) {
if (event.keyCode === 27) // ESCAPE
that.hide(tooltip);
});
// close on click anywhere
document.documentElement.addEventListener("click", function() {
that.hide(tooltip);
});
// but do not close on click inside
tooltip.addEventListener("click", function(event) {
event.stopPropagation();
});
// close when mouse leaves window
document.documentElement.addEventListener("mouseleave", function() {
that.hide(tooltip);
});
};
var installElementListenersRecursive = function(element, tooltip) {
if (that.hasTooltip(element))
that.installMouseListeners(element, tooltip);
var children = element.children;
for (var i = 0; i < children.length; i++)
installElementListenersRecursive(children[i], tooltip);
};
Application
As you've seen, I have installed different kinds of tooltips to this Blog page (different colors, fixed size). How this is done you can see in following code sample. Hover it to read its explanation.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | <script type="text/javascript"> window.addEventListener("load", function() { var mgr = tooltipManager();
var tooltip12 = mgr.buildTooltip(); tooltip12.style["background"] = "#FFFF66"; var elem1 = document.getElementById("elem-1"); mgr.installMouseListeners(elem1, tooltip12); var elem2 = document.getElementById("elem-2"); mgr.installMouseListeners(elem2, tooltip12);
var tooltip3 = mgr.buildTooltip(); tooltip3.style["background"] = "#FFFF66"; tooltip3.style["width"] = "30em"; tooltip3.style["height"] = "20em"; var elem3 = document.getElementById("elem-3"); mgr.installMouseListeners(elem3, tooltip3);
var tooltip4 = mgr.buildTooltip(); tooltip4.style["background"] = "#ADFF85"; tooltip4.style.cssText += "border-radius: 10px;"; var elem4 = document.getElementById("elem-4"); mgr.installMouseListeners(elem4, tooltip4);
var tooltipDefault = mgr.buildTooltip(); tooltipDefault.style["background"] = "#FFFF80"; var elemDefault = document.getElementById("elem-default"); mgr.install(elemDefault, tooltipDefault); }); </script>
|
I create just one tooltip-manager,
as I do not want several tooltips to be visible at the same time.
I use the builder-function to create a tooltip and style it. Then I connect it to two elements, elem-1
and elem-2
.
I do this one more time and give it a fixed CSS-size for elem-3
.
Once again for elem-4
, this time with a different color and rounded corners.
Then I build a tooltip for the rest of the document.
I connect it recursively to all child elements below elem-default
,
which is a div
enclosing the rest of the document.
Here is another application that shows how dynamically created tooltips can be implemented. In this case they show the tagName of the element they are hovering, and its viewport-relative coordinates.
The secret is "override". That's what frameworks live off.
<script type="text/javascript">
"use strict";
var myTooltipManager = tooltipManager();
myTooltipManager.logger = console;
myTooltipManager.hasTooltip = function(element) {
return true; // show tooltip on ANY element
};
var superGetTooltipContent = myTooltipManager.getTooltipContent;
myTooltipManager.getTooltipContent = function(element) {
if (myTooltipManager.getTooltipAttribute(element))
return superGetTooltipContent(element); // keep values of tooltip-attribute
// but override idrefs
var rect = element.getBoundingClientRect();
var x = Math.round(rect.left), y = Math.round(rect.top),
w = Math.round(rect.right - rect.left), h = Math.round(rect.bottom - rect.top);
return "<"+element.tagName+"> BoundingClientRect left="+x+", top="+y+", width="+w+", height="+h;
};
var tooltip = myTooltipManager.install();
tooltip.style["background"] = "#AAFF66";
</script>
Known Bugs
If you hover the line
var installMouseListeners = function(element, tooltip)
in the source outline above, you might notice that you can not reach the tooltip to copy the code in case the tip is BELOW the element. This is because the element showing that function contains child-elements also having tooltips. Moving the mouse down towards the tooltip causes the then hovered child element to pop up its tooltip.
Workaround: make the tooltip appear ABOVE the element. There won't be child elements. You can achieve this by scrolling the page down until the element is below the middle of the viewport. (Tooltips always try to show on that side of the element or mouse point where the most space is.)
Summary
I've written, including documentation, approximately 400 lines of JS code. (Much more I've written to test it, and to document it on this Blog.) The tips behave quite nice. Colors, borders, shapes are a question of taste and will be done per-page, one can apply any style to the tooltip without changing the JS code.
Make this all-browser-compatible? I've tested it with Firefox, Chrome, and Opera. Future will show whether addEventListener() and all the other standardization proposals will prevail (I believe they will).
An interesting question would be if I can add an optional close-timer by extending the existing implementation without changing it. Maybe this will become a follower Blog :-)
ɔ⃝ This code is free of any legal regulations.
ɔ⃝ Fritz Ritzberger, 2014-12-29