Predictive Text

Predictive Text

The predictive text pattern allows users to type in values or to select values from a list of possible matches that appears after the user starts typing. Screen readers announce the availability of predictive text options, and users can select the options with keyboard, touch, or mouse.



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

Initial HTML Markup

<div class="deque-predictive-text">
  <form id="chooseState">
     <label for="acInput" id="acInputLb">What state do you live in?</label>
    <div id="states-predictive-text"></div>
    <input type="submit" class="deque-button" value="Submit">
  </form>
</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 @predictive-text section:


      function createPredictiveText(data) {
        if (!data) {
          throw new Error('data must be provided');
        }
        if (!(0, _dataValidator.validateData)(data)) {
          throw new Error('data format invalid. Must either be an array of strings or an array of objects with a "label" field.');
        }

        var output = document.createElement('div');
        output.classList.add('deque-predictive-text-combobox');

        var input = document.createElement('input');
        var hintId = (0, _guidUtils.generateGuid)();
        var selectedHintClone = null;
        var selectedHintCloneIndex = null;
        // this prevents the browsers from helpfully suggesting
        // their own predictive text feedback.
        input.setAttribute('autocomplete', 'off');
        input.setAttribute('type', 'text');
        input.setAttribute('role', 'combobox');
        input.setAttribute('aria-expanded', 'false');
        input.setAttribute('aria-autocomplete', 'list');
        input.setAttribute('aria-owns', hintId);

        var hints = document.createElement('ul');
        hints.id = hintId;
        hints.classList.add('deque-predictive-text-hints');
        hints.setAttribute('aria-labelledby', 'acInput');
        hints.setAttribute('role', 'listbox');

        var live = document.createElement('p');
        live.classList.add('visuallyhidden');

        var notificationTimeout = void 0;

        function immediateNotification() {
          var highlight = hints.querySelector('.deque-predictive-text-hint.highlight');
          if (highlight) {
            var indexPositionText = null;
            var hintList = hints.querySelectorAll('li');
            var hintListLength = hintList.length;
            for (var i = 0; i < hintListLength; i++) {
              if (hintList[i].innerText == highlight.innerText) {
                indexPositionText = 'Option ' + (i + 1);
                selectedHintCloneIndex = indexPositionText;
              }
            }
            notify(live, indexPositionText + ' ' + highlight.innerText);
          }
        }

        function prepareNotification() {
          if (notificationTimeout) {
            clearTimeout(notificationTimeout);
            notificationTimeout = null;
          }

          var filteredOptions = hints.children;
          if (filteredOptions.length === 0) {
            return;
          }

          notificationTimeout = setTimeout(function () {
            if (filteredOptions.length != 0) {
              var isAre = filteredOptions.length === 1 ? 'is' : 'are';
              var optionText = filteredOptions.length === 1 ? 'option' : 'options';

              var message = 'There ' + isAre + ' currently ' + filteredOptions.length + ' ' + optionText + ' starting with ' + input.value.split('').join('') + '. Press down arrow to select an option';

              var ua = window.navigator.userAgent;
              var msie = ua.indexOf('Trident/');
              if (msie > 0) {
                message = 'There ' + isAre + ' currently ' + filteredOptions.length + ' ' + optionText + ' starting with ' + input.value.split('').join('') + '. Press up arrow and press down arrow two times to select an option';
              }
              var highlight = hints.querySelector('.deque-predictive-text-highlight');
              if (highlight) {
                message += ' Press down arrow for options, or Press enter to select ' + highlight.innerText;
              }
              notify(live, message);
            }
          }, 1200);
        }

        function showOptions() {
          var filteredOptions = input.value.length > 0 ? filter(data, input.value) : data;
          if (renderOptions(hints, filteredOptions)) {
            input.setAttribute('aria-expanded', 'true');
            hints.className = 'deque-predictive-text-hints expanded';
            prepareNotification();
            //let message = ' Press down arrow to select an option';
            //notify(live, message);
          } else {
            hints.className = 'deque-predictive-text-hints collapsed';
          }
        }

        input.addEventListener('input', function (e) {
          if (input.value.length > 0 && e.currentTarget.value != '') {
            showOptions();
          } else {
            input.setAttribute('aria-expanded', 'false');
            hints.className += ' collapsed';
            nav.clearList(hints);
            input.blur();
            input.focus();
            input.select();
          }
        });

        input.addEventListener('keyup', function (e) {
          e.currentTarget.parentNode.parentNode.parentNode.querySelector('.feedback-holder').innerHTML = '';
        });

        kb.onElementSpace(input, function (e) {
          if (e.ctrlKey && e.altKey) {
            e.preventDefault();
            e.stopPropagation();
            showOptions();
          }
        });

        kb.onElementUp(input, function (e) {
          e.preventDefault();
          e.stopPropagation();
          if (hints.children.length === 0) {
            return showOptions();
          }

          nav.highlightPrev(hints);
          var highlighted = hints.querySelector('[aria-selected="true"]');
          if (highlighted) {
            selectedHintClone = highlighted.innerHTML;
            input.setAttribute('aria-activedescendant', highlighted.id);
          }
          immediateNotification();
        });

        kb.onElementDown(input, function (e) {
          e.preventDefault();
          e.stopPropagation();

          if (hints.children.length === 0) {
            return showOptions();
          }
          nav.highlightNext(hints);
          var highlighted = hints.querySelector('[aria-selected="true"]');
          if (highlighted) {
            selectedHintClone = highlighted.innerHTML;
            input.setAttribute('aria-activedescendant', highlighted.id);
          }
          immediateNotification();
        });

        kb.onElementEnter(input, function (e) {
          e.preventDefault();
          e.stopPropagation();
          nav.confirmValue(input, hints);
          if (input.value == selectedHintClone) {
            notify(live, selectedHintCloneIndex + ' ' + input.value + ' selected');
          }

          input.setAttribute('aria-expanded', 'false');
          hints.className += ' collapsed';
          nav.clearList(hints);
          clearTimeout(notificationTimeout);
          input.focus();
          input.select();
          setTimeout(function () {
            input.selectionStart = input.selectionEnd = 10000;
          }, 0);
        });

        document.body.addEventListener('click', function (e) {
          var clickTarget = e.target;
          if (!(0, _containerUtils.elementIsChildOfElement)(clickTarget, output)) {
            nav.clearList(hints);
            input.setAttribute('aria-expanded', 'false');
            hints.className = 'deque-predictive-text-hints collapsed';
          }
        });

        /*
        document.querySelector('.deque-predictive-text .deque-button').addEventListener('focus', function(e){
          console.log(e.currentTarget);
        });
        
        document.body.addEventListener('focusin', (e) => {
          let focusTarget = e.target;
          if (!elementIsChildOfElement(focusTarget, output)) {
            nav.clearList(hints);
          }
        });
        */

        kb.onElementEscape(input, function (e) {
          e.preventDefault();
          e.stopPropagation();
          if (hints.children.length > 0) {
            nav.clearList(hints);
            input.setAttribute('aria-expanded', 'false');
            hints.className = 'deque-predictive-text-hints collapsed';
            clearTimeout(notificationTimeout);
          } else {
            input.value = '';
            selectedHintClone = null;
          }
        });

        hints.addEventListener('click', function (e) {
          e.preventDefault();
          e.stopPropagation();
          nav.confirmValue(input, hints);
          nav.clearList(hints);
          input.setAttribute('aria-expanded', 'false');
          hints.className = 'deque-predictive-text-hints collapsed';
          input.focus();
          if (selectedHintClone == input.value) {
            notify(live, selectedHintCloneIndex + ' ' + input.value + ' selected');
          }
          clearTimeout(notificationTimeout);
        });

        live.classList.add('live-update-region');
        live.setAttribute('aria-live', 'polite');

        output.appendChild(input);
        output.appendChild(hints);
        document.body.appendChild(live);

        output.getInputElement = function () {
          return input;
        };

        return output;
      }

      function renderOptions(list, options) {
        list.innerHTML = '';
        options.map(function (item) {
          return (0, _formatter.createHint)(list, item);
        }).forEach(function (item) {
          return list.appendChild(item);
        });

        var firstItem = list.querySelector('li');
        if (firstItem) {
          //firstItem.classList.add('deque-predictive-text-highlight');
          return true;
        }
      }

      function filter(data, prompt) {
        if (prompt.length === 0) {
          return [];
        }
        return data.filter(function (str) {
          return str.toLowerCase().indexOf(prompt.toLowerCase()) === 0;
        });
      }

      var clearNotificationTimeout = void 0;
      function notify(live, message) {
        if (clearNotificationTimeout) {
          clearTimeout(clearNotificationTimeout);
          clearNotificationTimeout = null;
        }

        live.innerText = message;
        clearNotificationTimeout = setTimeout(function () {
          live.innerText = '';
        }, 6000);
      }
      

In the @predictive-text-dataValidator section:


      function isString(v) {
        return v && typeof v === 'string';
      }

      function isValidObject(v) {
        return v && typeof v.label === 'string';
      }

      function validateData(data) {
        if (!Array.isArray(data)) {
          return false;
        }
        if (data.length === 0) {
          return false;
        }

        return data.every(isString) || data.every(isValidObject);
      }
      

In the @predictive-text-formatter section:


      function createHint(list, item) {
        if (item.label) {
          return wrapItem(list, item);
        }

        return wrapString(list, item);
      }

      function wrapItem(list, item) {
        var output = wrapString(list, item.label);
        output.classList.add('complex_item');
        output.$item = item;

        return output;
      }

      function wrapString(list, string) {
        var output = document.createElement('li');
        output.id = (0, _guidUtils.generateGuid)();
        output.innerText = string;
        output.classList.add('deque-predictive-text-hint');
        output.setAttribute('role', 'option');

        output.addEventListener('mouseover', function () {
          (0, _navigation.setHighlight)(list, output);
        });

        return output;
      }
      

In the @predictive-text-navigation section:


      function setHighlight(list, item) {
        var allItems = list.querySelectorAll('li');
        for (var i = 0; i < allItems.length; i++) {
          if (item === allItems[i]) {
            allItems[i].classList.add('highlight');
            allItems[i].setAttribute('aria-selected', 'true');
          } else {
            allItems[i].classList.remove('highlight');
            allItems[i].setAttribute('aria-selected', 'false');
          }
        }
      }

      function highlightNext(list) {
        if (list.children.length === 0) {
          return;
        }

        var target = void 0;
        var current = list.querySelector('.highlight');
        if (!current) {
          target = list.querySelector('li');
          return setHighlight(list, target);
        }

        target = current.nextElementSibling;
        if (!target) {
          target = list.querySelector('li');
        }

        setHighlight(list, target);
      }

      function highlightPrev(list) {
        if (list.children.length === 0) {
          return;
        }

        var target = void 0;
        var current = list.querySelector('.highlight');
        if (!current) {
          target = list.querySelector('li');
          return setHighlight(list, target);
        }

        target = current.previousElementSibling;
        if (!target) {
          var children = list.querySelectorAll('li');
          target = children[children.length - 1];
        }

        setHighlight(list, target);
      }

      function clearList(list) {
        if (list) {
          list.innerHTML = '';
        }
      }

      function confirmValue(input, list) {
        var li = list.querySelector('.highlight');
        if (li) {
          input.value = li.innerText;
        }
      }
      

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


      function elementIsChildOfElement(child, potentialParent) {
        while (child) {
          if (child === potentialParent) {
            return true;
          }

          child = child.parentNode;
        }

        return false;
      }

      function createFieldset(label) {
        var fieldset = document.createElement('fieldset');
        var legend = document.createElement('legend');
        legend.classList.add('legend'); // for easy lookup regardless of mode
        legend.id = (0, _guidUtils.generateGuid)();
        legend.innerText = label;
        fieldset.appendChild(legend);
        return fieldset;
      }

      function createLiveRegion() {
        var level = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'polite';
        var classes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [];

        var output = document.createElement('span');
        classes.forEach(function (c) {
          return output.classList.add(c);
        });
        output.id = (0, _guidUtils.generateGuid)();
        output.setAttribute('aria-live', level);
        output.classList.add('deque-visuallyhidden');
        output.innerText = '';
        output.notify = function (text) {
          // TODO: Clean this up...no need to extend the element prototype
          while (output.firstChild) {
            output.removeChild(output.firstChild);
          }
          var msg = document.createElement('div');
          msg.innerHTML = text;
          output.appendChild(msg);
        };

        return output;
      }
      

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

var data = ['Alabama', 'Alaska', 'American Samoa',
  'Arizona', 'Arkansas', 'California', 'Colorado', 'Connecticut',
  'Delaware', 'District of Columbia', 'Federated States of Micronesia',
  'Florida', 'Georgia', 'Guam', 'Hawaii', 'Idaho', 'Illinois', 'Indiana',
  'Iowa', 'Kansas', 'Kentucky', 'Louisiana', 'Maine', 'Marshall Islands',
  'Maryland', 'Massachusetts', 'Michigan', 'Minnesota', 'Mississippi',
  'Missouri', 'Montana', 'Nebraska', 'Nevada', 'New Hampshire', 'New Jersey',
  'New Mexico', 'New York', 'North Carolina', 'North Dakota',
  'Northern Mariana Islands', 'Ohio', 'Oklahoma', 'Oregon', 'Palau',
  'Pennsylvania', 'Puerto Rico', 'Rhode Island', 'South Carolina',
  'South Dakota', 'Tennessee', 'Texas', 'Utah', 'Vermont', 'Virgin Island',
  'Virginia', 'Washington', 'West Virginia', 'Wisconsin', 'Wyoming'];
var config = {};

var ac = deque.createPredictiveText(data, config);
var input = ac.getInputElement();
input.id = 'acInput';
// input.setAttribute('aria-label', 'Predictive text input - type slowly to get the results');

document
  .getElementById('states-predictive-text')
  .appendChild(ac);

/* The following is for optional form validation purposes */
input.setAttribute('data-validate', 'required');
var form1 = document.getElementById('chooseState');
deque.configureFormValidation(form1, {});

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-predictive-text label {
  float: left;
  width: 100%;
}
.deque-predictive-text #states-predictive-text {
  float: left;
}
.deque-predictive-text .deque-predictive-text-combobox {
  display: inline-block;
  position: relative;
  /*input[type='text'][aria-expanded='true'] + */
}
.deque-predictive-text .deque-predictive-text-combobox input[type='text'] {
  margin-bottom: -1px;
  height: 27px;
  margin-right: 10px;
  width: 200px;
}
.deque-predictive-text .deque-predictive-text-combobox .deque-predictive-text-highlight {
  background-color: #ffdc7c;
  outline: 1px solid transparent;
}
.deque-predictive-text .deque-predictive-text-combobox ul li.deque-predictive-text-hint {
  list-style-type: none;
  cursor: pointer;
  padding: 5px 10px;
}
.deque-predictive-text .deque-predictive-text-combobox .deque-predictive-text-hints {
  padding-left: 0;
  position: absolute;
  top: 13px;
  left: -1px;
  width: 100%;
  background: #ffffff;
  z-index: 1;
}
.deque-predictive-text .deque-predictive-text-combobox .deque-predictive-text-hints:empty {
  display: none;
}
.deque-predictive-text .deque-predictive-text-combobox .deque-predictive-text-hints {
  border: 1px solid #000000;
}
.deque-predictive-text input[type='submit'] {
  font-size: 15px;
  max-width: 374px;
  min-width: 120px;
  display: inline-block;
  margin-top: 12px;
  padding: 9px 12px 10px;
  border: solid 1px transparent;
  overflow: hidden;
  line-height: 1;
  text-align: center;
  white-space: nowrap;
  vertical-align: bottom;
  background: #006cc1;
  outline: none;
  color: #ffffff;
  appearance: none;
}
.deque-predictive-text input[type='submit']:hover {
  cursor: pointer;
  background-color: #006cc2;
  border-color: rgba(0, 0, 0, 0.4);
}
.deque-predictive-text input[type='submit']:focus {
  outline: 1px dashed #000000;
}

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

  • Wrap the HTML in <div class="deque-predictive-text">, for styling purposes.
  • Create a <form> inside the wrapper (this step may be optional, depending on the functionality of the overall experience you plan to create).
  • Add a <div> or <span> container with a unique ID. The predictive text <input> pattern will be inserted into this container.
  • Create a <label> and assign a for value that can be used to refer to the ID of the predictive text pattern (e.g. for="acInput" in this example). Note that the for value must NOT refer to the ID of the container that you specified above. It will refer to the ID of the predictive text <input> element (which the JavaScript will create).

Step 3: Add JavaScript

  1. Create an array of items to be used as possible predictive text values.
  2. Pass an array of data into createPredictiveText() to get an HTML element back.
  3. Add it to the DOM.

Note

The following rules dictate the data formatting requirements. The data MUST:

  • Be an array: The only acceptable type for data is an array.
  • Contain either strings or items with a 'label' field: Each item in your array must either be a string or an object with a label field which contains a string.
  • Be homogenous: No mixing - either every item in your array is a string, or every item is an object with a label field. You may not mix the two.