Tooltip
Tooltip
Overview
A tooltip provides extra information about a form field, a link, a button, or other focusable element. It must be triggered by both focus and hover events and remains on the screen as long as the trigger has the focus. The focus does not move to the tooltip.
Turn on a screen reader to experience this example in action.
Initial HTML Markup
Tooltip for inputs:
<p>
<label for="nameInput">First Name</label>
<input id="nameInput" type="text" autocomplete="given-name" data-deque-tooltip="Your given name"
class="deque-input">
</p>
<p>
<label for="lastInput">Last Name</label>
<input id="lastInput" type="text"
autocomplete="family-name"
data-deque-tooltip="Your surname or family name" class="deque-input">
</p>
Tooltip for links:
<p>
<a id="demo-link1" href="javascript:void(0);"
data-deque-tooltip="Tooltips can contain markup such as
<strong>&lt;strong&gt;</strong> and <em>&lt;em&gt;</em>
and even images:<br> <img src='assets/images/module-aria/sun.png'
alt='Emoji smiling sun' height='24' width='24'>">
Link 1
</a>
</p>
<p>
<a id="demo-link2" href="javascript:void(0);"
data-deque-tooltip="This is non-functional demo
link with a tooltip">
Link 2
</a>
</p>
Tooltip for buttons:
<p>
<button id="demo-button1" class="deque-button"
data-deque-tooltip="This particular tooltip is longer than the others,
and spans several rows. The height is calculated automatically by the
script and the offset is applied appropriately. It is possible to create
long tooltips, but it is NOT recommended. The aria-describedby attribute
does not allow screen reader users to pause in the middle of a tooltip
and start again where they left off. They have to focus again on
the element and listen to the whole tooltip from the beginning.">
Button 1
</button>
<button id="demo-button2" class="deque-button"
data-deque-tooltip="This is non-functional demo link with a tooltip">
Button 2
</button>
</p>
<p>
<button id="demo-button3" class="deque-button" data-deque-tooltip="This is a non-functional demo button with a tooltip">Button 3</button>
</p>
JavaScript
Required: The complete JavaScript file (for all patterns in the library): deque-patterns.min.js
Key parts of the JavaScript file
Note: The code below is functional only in the context of the complete JavaScript file.
In the @tooltip section:
function createTooltip(trigger) {
var tipText = trigger.getAttribute('data-deque-tooltip');
var tipID = (0, _guidUtils.generateGuid)();
/* wrap the element (trigger) in a container */
var tipWrapper = document.createElement('span');
tipWrapper.classList.add('deque-tooltip-wrapper');
trigger.parentNode.insertBefore(tipWrapper, trigger);
/* move element into the wrapper */
tipWrapper.appendChild(trigger);
/* create tooltip */
var tip = document.createElement('span');
tip.setAttribute('role', 'tooltip');
tip.id = tipID;
tip.classList.add('deque-tooltip');
tip.innerHTML = '<span aria-label="Tooltip :"></span>' + tipText;
/* move tooltip into the wrapper */
tipWrapper.appendChild(tip);
/* carry over CSS values */
var triggerStyle = window.getComputedStyle(trigger);
var triggerCSS = [];
triggerCSS['display'] = triggerStyle.getPropertyValue('display');
function getTriggerPosition() {
/* get element's size and position */
var triggerPosition = [];
triggerPosition['width'] = trigger.offsetWidth;
triggerPosition['height'] = trigger.offsetHeight;
return triggerPosition;
}
function getTooltipPosition(triggerPosition) {
/* get/set positioning preferences */
var triggerPositionPref = trigger.getAttribute('data-deque-tooltip-position');
/* get tooltip dimensions */
var tipStyle = window.getComputedStyle(tip);
var tipCSS = [];
tipCSS['width'] = tipStyle.getPropertyValue('width');
tipCSS['width'] = tipCSS['width'].replace('px', '');
tipCSS['width'] = Number(tipCSS['width']);
tipCSS['height'] = tipStyle.getPropertyValue('height');
tipCSS['height'] = tipCSS['height'].replace('px', '');
tipCSS['height'] = Number(tipCSS['height']);
/*triggerPositionPref is currently not being used in the code,
but the idea is that developers could specify where the
tooltip should be placed: top, right, bottom, left, or top-right */
if (triggerPositionPref == 'right') {
tip.style.left = '5px';
// put other calculations here
}
if (triggerPositionPref == 'top') {
// put calculations here
} else {
var newLeft = triggerPosition['width'] - 10;
//var newTop = tipCSS['height'] + 5;
tip.style.left = newLeft - 10 + 'px';
//tip.style.top = '-' + (newTop) + 'px';
tip.style.bottom = '120%';
}
}
(0, _noclobberUtils.noClobber)(trigger, 'aria-describedby', tipID);
var causes = {
INITIAL: 'initial',
FOCUS: 'focus',
MOUSE: 'mouse',
ESCAPE: 'escape'
};
var currentCause = causes.INITIAL;
function showTip(cause) {
if (currentCause === causes.FOCUS) {
// prevents 'MOUSE' cause from replacing 'FOCUS' cause.
return;
}
tip.classList.remove('deque-hidden');
tip.setAttribute('aria-hidden', 'false');
currentCause = cause;
var p = getTriggerPosition();
getTooltipPosition(p);
/* For some reason, the position is incorrectly
calculated on inline elements the first time the
getTooltipPosition function runs, so it has to run
twice. Possible bug in the script's logic? */
getTooltipPosition(p);
}
function hideTip(cause) {
if (cause === causes.ESCAPE || currentCause === cause) {
tip.classList.add('deque-hidden');
tip.setAttribute('aria-hidden', 'true');
currentCause = '';
}
}
function escapeHandler() {
hideTip(causes.ESCAPE);
}
var cancelElementEscape = (0, _keyboardUtils.onElementEscape)(document.body, escapeHandler);
var focusHandler = showTip.bind(null, causes.FOCUS);
var blurHandler = hideTip.bind(null, causes.FOCUS);
var overHandler = showTip.bind(null, causes.MOUSE);
var outHandler = hideTip.bind(null, causes.MOUSE);
trigger.addEventListener('focus', focusHandler);
trigger.addEventListener('blur', blurHandler);
trigger.addEventListener('mouseover', overHandler);
trigger.addEventListener('mouseout', outHandler);
/*// eslint-disable-next-line no-console
console.log(trigger);
// eslint-disable-next-line no-console
console.log(trigger.parentNode.querySelector('.deque-tooltip'));*/
if (trigger.parentNode.querySelector('.deque-tooltip')) {
trigger.parentNode.querySelector('.deque-tooltip').addEventListener('mouseover', overHandler);
trigger.parentNode.querySelector('.deque-tooltip').addEventListener('mouseout', outHandler);
}
hideTip(causes.INITIAL);
return function disableTooltip() {
hideTip(causes.ESCAPE);
cancelElementEscape();
try {
tip.remove();
} catch (err) {
/*no op*/
}
trigger.removeEventListener('focus', focusHandler);
trigger.removeEventListener('blur', blurHandler);
trigger.removeEventListener('mouseover', overHandler);
trigger.removeEventListener('mouseout', outHandler);
trigger.removeAttribute('aria-describedby');
};
}
function initializeAllTooltips() {
var itemsToTip = document.querySelectorAll('[data-deque-tooltip]');
for (var i = 0; i < itemsToTip.length; i++) {
createTooltip(itemsToTip[i]);
}
}
initializeAllTooltips();
In the @guidUtils section:
/*
note - not a true guid. I prepend 'g' because
the ID of an element cannot start with a numeral
*/
function generateGuid() {
var S4 = function S4() {
return ((1 + Math.random()) * 0x10000 | 0).toString(16).substring(1);
};
return 'g' + (S4() + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4());
}
In the @keyboardUtils section:
var KEYS = exports.KEYS = {
BACKSPACE: 8,
TAB: 9,
ENTER: 13,
SHIFT: 16,
CTRL: 17,
ALT: 18,
ESCAPE: 27,
SPACE: 32,
LEFT: 37,
RIGHT: 39,
UP: 38,
DOWN: 40,
F10: 121,
HOME: 36,
END: 35,
PAGE_UP: 33,
PAGE_DOWN: 34
};
function bindElementToEventValue(element, eventName, testValue, handler) {
function localHandler(e) {
if (e.which === testValue) {
handler(e);
}
}
element.addEventListener(eventName, localHandler);
return function () {
element.removeEventListener(eventName, localHandler);
};
}
function bindElementToKeypressValue(element, testValue, handler) {
return bindElementToEventValue(element, 'keypress', testValue, handler);
}
function bindElementToKeydownValue(element, testValue, handler) {
return bindElementToEventValue(element, 'keydown', testValue, handler);
}
function onElementEnter(element, handler) {
return bindElementToKeydownValue(element, KEYS.ENTER, handler);
}
function onElementEscape(element, handler) {
return bindElementToKeydownValue(element, KEYS.ESCAPE, handler);
}
function onElementSpace(element, handler) {
return bindElementToKeypressValue(element, KEYS.SPACE, handler);
}
function onElementLeft(element, handler) {
return bindElementToKeydownValue(element, KEYS.LEFT, handler);
}
function onElementRight(element, handler) {
return bindElementToKeydownValue(element, KEYS.RIGHT, handler);
}
function onElementUp(element, handler) {
return bindElementToKeydownValue(element, KEYS.UP, handler);
}
function onElementDown(element, handler) {
return bindElementToKeydownValue(element, KEYS.DOWN, handler);
}
function onElementHome(element, handler) {
return bindElementToKeydownValue(element, KEYS.HOME, handler);
}
function onElementEnd(element, handler) {
return bindElementToKeydownValue(element, KEYS.END, handler);
}
function onElementPageUp(element, handler) {
return bindElementToKeydownValue(element, KEYS.PAGE_UP, handler);
}
function onElementPageDown(element, handler) {
return bindElementToKeydownValue(element, KEYS.PAGE_DOWN, handler);
}
function onElementF10(element, handler) {
return bindElementToKeydownValue(element, KEYS.F10, handler);
}
function isAlphaNumeric(charCode) {
return charCode >= 48 && charCode <= 90 /* numbers, uppercase letters */
|| charCode >= 97 && charCode <= 122 /* lowercase letters */;
}
function onElementCharacter(element, handler) {
function localHandler(e) {
var charCode = e.which;
if (isAlphaNumeric(charCode)) {
handler(e);
}
}
element.addEventListener('keypress', localHandler);
return function () {
element.removeEventListener('keypress', localHandler);
};
}
function trapEnter(element) {
onElementEnter(element, function (e) {
e.stopPropagation();
e.preventDefault();
});
}
In the @noclobberUtils section:
/* Whitespace RegExp. */
var whitespace = /\s+/;
/**
* Add `attr=val` without clobbering an existing value.
* @param {Element} element [Element object]
* @param {String} attr [Attribute to update]
* @param {Array} ids [rest parameters of ids to add]
*/
function noClobber(element, attr) {
var current = element.getAttribute(attr);
// if there is no value, just add the new one
for (var _len = arguments.length, ids = Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) {
ids[_key - 2] = arguments[_key];
}
if (!current) {
return element.setAttribute(attr, ids.join(' '));
}
// remove "extra" whitespace, then split by any whitespace
var parts = current.trim().split(whitespace);
ids.map(function (val) {
if (!~parts.indexOf(val)) {
parts.push(val);
}
});
// set new val
return element.setAttribute(attr, parts.join(' '));
}
Note: No initialization code is necessary. All functionality is contained in the external JavaScript file.
CSS
Required: The complete CSS file (for all patterns in the library): deque-patterns.min.css
Key styles within the CSS file (other styles may also be necessary):
.deque-tooltip-wrapper {
position: relative;
}
.deque-tooltip {
box-sizing: border-box;
font-size: 13px;
position: absolute;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.3);
min-width: 104px;
max-width: 250px;
margin-right: -340px;
padding: 8px 6px;
line-height: 16px;
z-index: 700;
}
.deque-tooltip:after {
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 10px solid #b2b2b2;
top: 100%;
position: absolute;
bottom: 0;
height: 0;
width: 0;
left: -1%;
content: "";
}
Implementation Instructions
Step 1: Add Dependencies
Add deque-patterns.min.css in the <head>
of the document.
<link rel="stylesheet" type="text/css" href="deque-patterns.min.css">
Add a script link to deque-patterns.min.js to the bottom of the page.
<script type="text/javascript" src="deque-patterns.min.js"></script>
Step 2: Add HTML
- Create a container with
data-deque-tooltip=""
with whatever you want the tooltip to display. The javascript will automatically initialize all tooltips with this attribute.