Tooltip Dialog

Tooltip Dialog

A tooltip dialog is a dialog that pops up in response to a user action, near the current point of focus, similar to the way a tooltip does. It is usually intended to be modal, but this is not as strictly observed as it would normally be with a regular dialog. And though a tooltip often appears on focus or on hover, forcing a dialog to appear on focus or hover is not expected or advisable, because it would move the focus without any prior warning. It is better to allow the user to activate the dialog purposefully.



Turn on a screen reader to experience this example in action.

Initial HTML Markup

<div class="deque-wrapper">
  <div class="one-wrap">
    <label for="targetField">Recipient</label>
    <input type="text" id="targetField" style="max-width: 70%">
    <button class="helper deque-button" id="targetFieldHelper" type="button" aria-label="Prepopulate recipient"></button><div aria-label="Prepopulate Recipients" role="dialog" id="" class="deque tooltip hidden" aria-hidden="true"><div class="tabtrap" tabindex="0"></div><div id="recipients" data-tabtrap="">
    <form action="javascript:void(0)">
      <fieldset class="deque" tabindex="-1">
        <legend>Select a name:</legend>
        <label>Alice
          <input type="radio" name="contactList" value="Alice" checked="">
        </label>
        <label>Bob
          <input type="radio" name="contactList" value="Bob">
        </label>
        <label>Paul
          <input type="radio" name="contactList" value="Paul" aria-labelledby="radioLabel">
        </label>
      </fieldset>
      <p><button id="confirm" class="deque-button">Confirm</button></p>
    </form>
  </div><div class="tabtrap" tabindex="0"></div></div>
  </div>
</div>

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-dialog section:


      function createDialogTooltip(trigger, _ref) {
        var contentID = _ref.contentID,
          submitID = _ref.submitID,
          cancelID = _ref.cancelID,
          onCancel = _ref.onCancel,
          onSubmit = _ref.onSubmit,
          onOpen = _ref.onOpen,
          label = _ref.label,
          position = _ref.position;

        var content = document.getElementById(contentID);
        var tipID = (0, _guidUtils.generateGuid)();
        var tip = document.createElement('div');

        if (!position) {
          position = getTipPosition;
        }

        tip.setAttribute('aria-label', label);

        tip.setAttribute('role', 'dialog');
        tip.id = tipID;

        // see tooltip/style.less for tooltip style.
        tip.classList.add('deque');
        tip.classList.add('tooltip');

        tip.appendChild(content);
        content.classList.remove('hidden');

        trigger.parentElement.insertBefore(tip, trigger);
        trigger.parentElement.insertBefore(trigger, tip);

        var submitButton = submitID ? document.getElementById(submitID) : null;
        var cancelButton = cancelID ? document.getElementById(cancelID) : null;

        function submit() {
          onSubmit && onSubmit();
          hideTip();
        }

        function cancel(returnFocus) {
          onCancel && onCancel();
          hideTip();
          if (returnFocus) {
            trigger.focus();
          }
        }

        function showTip() {
          document.addEventListener('mousedown', cancel);
          onOpen && onOpen();
          trigger.setAttribute('aria-describedby', tip.id);
          tip.classList.remove('hidden');
          tip.setAttribute('aria-hidden', 'false');

          (0, _focusUtils.getFirstFocusableChild)(tip).focus();
        }

        function hideTip() {
          document.removeEventListener('mousedown', cancel);
          tip.classList.add('hidden');
          tip.setAttribute('aria-hidden', 'true');
          trigger.removeAttribute('aria-describedby');
        }

        trigger.addEventListener('click', function () {
          if (tip.classList.contains('hidden')) {
            showTip();
          } else {
            hideTip();
          }
        });

        tip.addEventListener('mousedown', function (e) {
          e.stopPropagation();
        });

        var cancelBodyListener = (0, _keyboardUtils.onElementEscape)(document.body, cancel.bind({}, true));

        if (submitButton) {
          submitButton.addEventListener('click', submit);
        }

        if (cancelButton) {
          cancelButton.addEventListener('click', cancel);
        }

        function getTipPosition(trigger, tip) {

          var triggerBounds = trigger.getClientRects()[0];
          var tipBounds = tip.getClientRects()[0];

          return {
            left: triggerBounds.left + triggerBounds.width + 10,
            top: triggerBounds.top + triggerBounds.height / 2 - tipBounds.height / 2
          };
        }

        hideTip();
        (0, _focusUtils.initTabTrap)(tip);

        return function disableTooltip() {
          trigger.removeEventListener('focus', showTip);
          cancelBodyListener();
          hideTip();
          if (tip.parentNode) {
            tip.parentNode.removeChild(tip);
          }
        };
      }
      

In the @focusUtils section:


      var focusableQuery = 'input:not([tabindex^="-"]), select:not([tabindex^="-"]), textarea:not([tabindex^="-"]), button:not([tabindex^="-"]), object:not([tabindex^="-"]), [href]:not([tabindex^="-"]), [tabindex]:not([tabindex^="-"]):not(.tabtrap)';

      function getFirstFocusableChild(element) {
        return element.querySelector(focusableQuery);
      }

      function getNextFocusableChild(element, current) {
        var all = (0, _selectionUtils.queryAll)(focusableQuery);
        var targetReturnIndex = all.indexOf(current) + 1;

        if (targetReturnIndex <= all.length - 1) {
          return all[targetReturnIndex];
        }

        return null;
      }

      function getLastFocusableChild(element) {
        var all = element.querySelectorAll(focusableQuery);
        return all[all.length - 1];
      }

      function initTabTrap(element) {
        function createTrap() {
          var trap = document.createElement('div');
          trap.classList.add('tabtrap');
          trap.setAttribute('tabindex', '0');

          return trap;
        }

        function applyTraps(element, firstTrap, lastTrap) {
          firstTrap.addEventListener('focus', function () {
            getLastFocusableChild(element).focus();
          });

          lastTrap.addEventListener('focus', function () {
            getFirstFocusableChild(element).focus();
          });

          element.insertBefore(firstTrap, element.firstChild);
          element.appendChild(lastTrap);
        }

        var firstTrap = createTrap();
        var lastTrap = createTrap();

        applyTraps(element, firstTrap, lastTrap);
      }
      

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 @selectionUtils section:


      function queryAll(selector, context) {
        context = context || document;
        return (0, _collectionUtils.toArray)(context.querySelectorAll(selector));
      }
      

In the @collectionUtils section:


      function toArray(arraylike) {
        return Array.prototype.slice.call(arraylike);
      }
      

Required: Initialization JavaScript (with functionality specific to individual pattern instances):


function initiatecreateDialogTooltip() {
  var targetField = document.getElementById('targetField');
  var targetFieldHelper = document.getElementById('targetFieldHelper');
  deque.createDialogTooltip(targetFieldHelper, {
    contentID: 'recipients',
    submitID: 'confirm',
    onSubmit: prepopulateRecipient,
    label: 'Prepopulate Recipients'
  });
}

function prepopulateRecipient() {
  var recipients = document.querySelector('[name="contactList"]:checked').value;
  targetField.value = recipients;
  targetField.focus();
}


if (window.addEventListener)  {
  window.addEventListener('load', initiatecreateDialogTooltip, false);
} 
else if (window.attachEvent) {
  window.attachEvent('onload', initiatecreateDialogTooltip);
}

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-wrapper .one-wrap {
  position: relative;
}
.deque-wrapper .one-wrap label {
  display: inline-block;
  margin: 5px;
}
.deque-wrapper .one-wrap input[type='radio'] {
  display: inline-block;
}
.deque-wrapper .one-wrap #targetField,
.deque-wrapper .one-wrap .helper {
  display: inline-block;
  vertical-align: middle;
  margin: 0;
}
.deque-wrapper .one-wrap .helper {
  min-width: 30px;
  width: 30px;
  max-width: 30px;
  padding: 5px;
}
.deque-wrapper .deque.tooltip fieldset {
  outline: none;
  box-sizing: border-box;
  border-radius: 0;
}
.deque-wrapper [role='dialog'].deque.tooltip {
  position: absolute;
  top: -150px;
  /*left: 260px;*/
  right: 0px;
  background: #ffffff;
  border: 1px solid #cccccc;
  padding: 5px 15px 0 15px;
}
.deque-wrapper .helper:before {
  content: '\E946';
  font-family: 'mwf-glyphs';
  display: block;
}

Fonts

Note: You will need to edit the src for font-family:'mwf-glyphs' in the external CSS file.

Implementation Instructions

Implement the Tooltip Dialog pattern whenever you want to have a modal dialog that is rendered near the invoking element that is displayed when the mouse passes over or rests on that element. Further, implement this pattern when you also want to make this functionality available to keyboard-only users and users of screen readers.

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>

Fonts: Font glyphs are required for this pattern (as currently styled). The CSS file refers to them in a folder called "_fonts" as a child element of the folder in which the CSS file resides. The fonts, from the Microsoft Web Framework (MWF) are available at these links:

Step 2: Add HTML

The HTML must contain an element with a unique ID that can trigger the tooltip dialog, and it must contain the tooltip dialog itself, also with a unique ID. The tooltip dialog will be styled initially to be hidden from all users with display:none.

Step 3: Add JavaScript

The createTooltipDialog() function takes two arguments: the first is a reference to the DOM element that will trigger the tooltip to show up when it gets focus. The second is a config option that defines one required and two optional ID's, as well as three optional event handlers and an optional positioning function:

contentID* required
The id of the DOM node that you want to use as the content for this tooltip.
submitID
The ID of an element within the tooltip content area (probably a button) that, when clicked, should (1) invoke the onSubmit behavior if present, and (2) close the tooltip. Note: the button with this ID may have its own click handler independently of the tooltip. That will work, but if someone invokes tooltip submission by pressing enter, only the onSubmit function below will fire.
cancelID
The ID of an element within the tooltip content area (probably a button) that, when clicked, should (1) invoke the onCancel behavior if present, and (2) close the tooltip. Note: the button with this ID may have its own click handler independently of the tooltip. That will work, but if someone invokes tooltip cancellation by pressing escape, only the onCancel function below will fire.
onOpen
A function that fires when the tooltip first opens.
onSubmit
A function that fires when the tooltip is submitted. It can be submitted either by pressing enter while the tooltip has focus, or by pressing the button whose ID was passed in as 'submitID'.
onCancel
A function that fires when the tooltip is cancelled. It can be cancelled either by pressing escape while the tooltip has focus, or by clicking the button whose ID was passed in as 'cancelID'.
position
An optional positioning function. It takes two arguments: trigger is a reference to the element that triggered the tooltip, and tip is a reference to the tooltip itself. It returns an object with keys left and top, which are position values that get treated as CSS pixel values. The tip is absolutely positioned.