Typeahead

function
 Typeahead() 

Option name Type Description
el Element
params Object

Typeahead constructor

function Typeahead(el, params) {

  if (!el) {
    return;
  }

  this._setParams(this.defaults, true);
  this._setParams(params || {});
  this._cacheElements(el);

  this._maintainFocus(function() {
    this._parseParams();
    this._bindEventListenerCallbacks();
    this._addEventListeners();
  });
}

Typeahead.prototype = {

_setParams

property
 _setParams 

Include common functionality.

_setParams: Base.setParams,
_triggerEvent: Base.triggerEvent,
remove: Base.remove,

_whitelistedParams

property
 _whitelistedParams 

Whitelisted parameters which can be set on construction.

_whitelistedParams: ['actionCodes', 'format', 'placeholder', 'matchPlaceholderSize', 'onChange', 'onFocus', 'onBlur', 'onBackspace', 'onEnd'],

defaults

property
 defaults 

Default values for internal properties we will be setting.
These are set on each construction so we don't leak properties
into the prototype chain.

defaults: {
  el: null,
  inputEl: null,
  placeholderEl: null,
  placeholder: null,
  characters: null,
  format: null,
  ignoreCodes: [
    9, // Tab
    16, // Shift
    17, // Ctrl
    18, // Alt
    20, // CAPS
    91, // Meta
    93, // Alt
    112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123 // F1-F12
  ],
  actionCodes: {
    BACKSPACE: 8,
    DELETE: 46,
    LEFT: 37,
    RIGHT: 39
  },
  pasteCode: 86, // v
  pauseBlurFocus: 0,
  matchPlaceholderSize: false,
  isFocused: false,
  isRunning: false,
  onChange: null,
  onFocus: null,
  onBlur: null,
  onBackspace: null,
  onEnd: null,
  _atEnd: false,
  _oldVal: null,
  _onInputBound: null,
  _onKeydownBound: null,
  _onFocusBound: null,
  _onBlurBound: null,
  _onPlaceholderClickBound: null
},

run

method
 run() 

Option name Type Description
cursorIndex Number
params Object

Run the formatting. @todo: rename this.

run: function(cursorIndex, params) {

  params = params || {};

  if (this.isRunning) return;

  this.isRunning = true;

  var oldVal = this.inputEl.value;
  var val = '';
  var placeholder = '';
  var i = 0;
  var len = this.format.length;
  var skipCount = 0;
  var valDone = false;

  for (; i < len; i++) {

    // Add numbers
    if (this.format[i] === '\\d') {

      if (this.characters[i - skipCount]) {
        val += this.characters[i - skipCount];
      }
      else {
        valDone = true;
      }

      placeholder += valDone ? this.placeholder[i] : this.characters[i - skipCount];
    }
    // Placeholder characters
    else {

      if (!valDone) {
        val += this.format[i];
      }

      placeholder += this.format[i];

      skipCount++;
    }
  }

  if (this.isFocused) {
    cursorIndex = cursorIndex === undefined ? this._getCaretEnd() : cursorIndex;
  }

  // If there are no characters, set the cursorIndex to be the last placeholder entry.
  if (this.isFocused && !this.characters.length) {
    cursorIndex = val.length;
  }

  // No characters and we shouldn't use just placeholder values
  if (!this.characters.length && params.notOnlyPlaceholders) {
    val = '';
  }

  this.inputEl.value = val;
  this.placeholderEl.innerHTML = placeholder;

  this._updateWidth();

  if (this.isFocused) {
    this._setCaretPositionTranslated(cursorIndex);
  }

  if (val !== oldVal) {
    this._triggerEvent(this.inputEl, 'input');
  }

  this.isRunning = false;

  if (val !== oldVal) {
    (this.onChange || noop)(val, oldVal, this.inputEl);
  }

  if (!this._atEnd && this.isFocused && this.characters.length === this.maxLength && this._caretIsAtEnd()) {
    this._atEnd = true;
    (this.onEnd || noop)(this);
  } else {
    this._atEnd = false;
  }
},

addCharacterAtIndex

method
 addCharacterAtIndex() 

Option name Type Description
character String
start Number
end Number
skipCheck Boolean

Add a character to the characters array at a given index.

addCharacterAtIndex: function(character, start, end, skipCheck) {

  // Don't add at an index beyond what we can support.
  if (this.maxLength && start >= this.maxLength) {
    return;
  }

  if (!skipCheck) {

    var re;

    // Try to build a regex for this format character.
    try {
      re = new RegExp(this.format[start]);
    } catch (e) {}

    if (!re || !re.exec(character)) {
      return;
    }
  }

  this.characters.splice(start, end - start, character);

  // If we've added at an index that pushes the length beyond what we support,
  // remove the trailing characters.
  if (this.maxLength && this.characters.length > this.maxLength) {
    this.characters.splice(this.maxLength, this.characters.length);
  }

  this.run(start + 1);
},

addCharacterAtCaret

method
 addCharacterAtCaret() 

Option name Type Description
character String

Add a character at the position of the caret.

addCharacterAtCaret: function(character) {

  var pos = this._getCaretStart();
  var re;

  // If we're beyond the bounds of the format, stop.
  if (this.format[pos] === undefined) {
    (this.onEnd || noop)(this, character);
    return;
  }

  // Try to build a regex for this format character.
  try {
    re = new RegExp(this.format[pos]);
  } catch (e) {}

  // We couldn't build a regex (so it's invalid) or the regex failed (so it's invalid)
  if (!re || !re.exec(character)) {
    if (this._moveCaret('right')) {
      this.addCharacterAtCaret(character);
    }
    return;
  }

  this.addCharacterAtIndex(character, this._getCaretStartTranslated(), this._getCaretEndTranslated(), true);
},

removeCharacterAtIndex

method
 removeCharacterAtIndex() 

Option name Type Description
index Number
length Number

Optional

offset Number

Optional

Remove a character from the character array by index.

removeCharacterAtIndex: function(index, length, offset) {

  // Don't want a negative splice length or else we start
  // removing characters from the end.
  if (index + offset < 0) {
    return;
  }

  length = length !== undefined ? length : 1;
  this.characters.splice(index + offset, length);
  this.run(index + (offset || 1));
},

removeCharacterAtCaret

method
 removeCharacterAtCaret() 

Option name Type Description
offset Number

Optional An offset from the current position.

Remove the character at the caret.

removeCharacterAtCaret: function(offset) {

  var start = this._getCaretStartTranslated();
  var end = this._getCaretEndTranslated();
  var length = 1;
  var tmp;

  if (start !== end) {

    // If the end is less than the start, the user dragged from right to left.
    // Just swap them to make it easier to handle.
    if (end < start) {
      tmp = start;
      start = end;
      end = tmp;
    }

    // The length of characters removed
    length = end - start;

    // Bump the start position @todo: haven't thought through why this is, but it's needed.
    start++;
  }

  this.removeCharacterAtIndex(start, length, offset);
},

removeCharactersInRange

method
 removeCharactersInRange() 

Remove the character in the current range.

removeCharactersInRange: function() {
  this.removeCharacterAtIndex(this._getCaretStartTranslated(), this._getCaretEndTranslated());
},

setValue

method
 setValue() 

Option name Type Description
value String

Set the value of the typeahead. Maintain the position of the caret.

setValue: function(value) {

  this.settingValue = true;
  this.pause();

  this.characters = (value + '').split('');
  this.run();

  if (this.isFocused) this._setCaretPosition(this._getCaretStart());

  this.resume();
  this.settingValue = false;
},

getValue

method
 getValue() 

Option name Type Description
asInt Boolean

Get the value as a parsed integer.

return String, Number

Get the value of the typeahead.

getValue: function(asInt) {
  return asInt && this.inputEl.value ? parseInt(this.inputEl.value, 10) : this.inputEl.value;
},

moveCaret

method
 moveCaret() 

Option name Type Description
pos Number

Move the caret position.

moveCaret: function(pos) {
  this._setCaretPositionTranslated(pos);
},

moveCaretToEnd

method
 moveCaretToEnd() 

Move the caret to the end of the input.

moveCaretToEnd: function() {
  this.moveCaret(this.characters.length);
},

moveCaretToStart

method
 moveCaretToStart() 

Move the caret to the start of the input.

moveCaretToStart: function() {
  this.moveCaret(0);
},

pause

method
 pause() 

Pause events.

pause: function() {
  this.pauseBlurFocus++;
},

resume

method
 resume() 

Resume events.

resume: function() {
  this.pauseBlurFocus--;
},

clear

method
 clear() 

Clear the value.

clear: function() {
  this.pause();
  this.characters = [];
  this.run(0, {notOnlyPlaceholders: true});
  this.resume();
},

_cacheElements

method
 _cacheElements() 

Option name Type Description
el Object

Store a reference to the needed elements.

_cacheElements: function(el) {
  this.el = el;
  this.inputEl = this.el.querySelector('[type="text"], [type="email"], [type="phone"], textarea') || this._createDefaultInputElement();
  this.placeholderEl = this.el.querySelector('.spark-input__placeholder') || this._createDefaultPlaceholderElement();
},

_parseParams

method
 _parseParams() 

Parse parameters from the elements.

_parseParams: function() {

  // Store the value characters
  this.characters = this._parseCharacters(this.inputEl.value);

  // Store format
  this.format = this._parseFormat(this.format ? this.format : this.inputEl.getAttribute('data-typeahead-format'));

  // Store the original placeholder
  this.placeholder = this.placeholder ? this.placeholder : this.inputEl.getAttribute('placeholder').split('');

  // Get the total number of characters we can have
  this.maxLength = this._getCharactersAllowedCount(this.format);
},

_parseFormat

method
 _parseFormat() 

Option name Type Description
format String
return Array

Parse the format string into an array.

_parseFormat: function(format) {

  var i = 0;
  var len = format.length;
  var arr = [];
  var lastWasEscape = false;

  for (; i < len; i++) {
    if (format[i] === '\\' && !lastWasEscape) {
      lastWasEscape = true;
    } else {
      arr.push((lastWasEscape ? '\\' : '') + format[i]);
      lastWasEscape = false;
    }
  }

  return arr;
},

_parseCharacters

method
 _parseCharacters() 

Option name Type Description
characters String
return Array

Parse the characters string into an array, ignoring characters which don't
match the format requirements.

_parseCharacters: function(characters) {

  var chars = characters.split('');
  var i = 0;
  var len = characters.length;
  var regexes = [];
  var arr = [];

  for (; i < len; i++) {

    // Try to build a regex for this format character.
    try {
      // Make sure this format starts with an escape character. @todo: this is pretty limiting, but
      // it's necessary or else '-' or '+' matches properly.
      regexes[i] = this.format[i][0] === '\\' ? new RegExp(this.format[i]) : null;
    } catch (e) {}

    // If we were able to create a regex and our char passes, add it to the array
    // of characters to return.
    if (regexes[i] && regexes[i].exec(chars[i])) {
      arr.push(chars[i]);
    }
  }

  return arr;
},

_createDefaultInputElement

method
 _createDefaultInputElement() 

Create the default input element.

_createDefaultInputElement: function() {

  var el = document.createElement('input');
  el.className = 'spark-input__field';
  el.setAttribute('data-typeahead', '');
  el.setAttribute('type', 'tel');

  this.el.appendChild(el);

  return el;
},

_createDefaultPlaceholderElement

method
 _createDefaultPlaceholderElement() 

Create the default input element.

_createDefaultPlaceholderElement: function() {
  var el = document.createElement('span');
  el.className = 'spark-input__placeholder';
  this.el.appendChild(el);
  return el;
},

_getCharactersAllowedCount

method
 _getCharactersAllowedCount() 

Option name Type Description
format Array
return Number

Get the maximum number of characters allowed.

_getCharactersAllowedCount: function(format) {

  var i = 0;
  var len = format.length;
  var allowed = 0;

  for (; i < len; i++) {
    if (format[i] === '\\d') {
      allowed++;
    }
  }

  return allowed;
},

_bindEventListenerCallbacks

method
 _bindEventListenerCallbacks() 

Create bound versions of event listener callbacks and store them.
Otherwise we can't unbind from these events later because the
function signatures won't match.

_bindEventListenerCallbacks: function() {
  this._onKeydownBound = this._onKeydown.bind(this);
  this._onInputBound = this._onInput.bind(this);
  this._onFocusBound = this._onFocus.bind(this);
  this._onBlurBound = this._onBlur.bind(this);
  this._onPlaceholderClickBound = this._onPlaceholderClick.bind(this);
},

_addEventListeners

method
 _addEventListeners() 

Add event listeners to keypress and keydown.

_addEventListeners: function() {
  this.inputEl.addEventListener('keydown', this._onKeydownBound, false);
  this.inputEl.addEventListener('input', this._onInputBound, false);
  this.inputEl.addEventListener('focus', this._onFocusBound, false);
  this.placeholderEl.addEventListener('click', this._onPlaceholderClickBound, false);
},

_removeEventListeners

method
 _removeEventListeners() 

Add event listeners to keypress and keydown.

_removeEventListeners: function() {
  this.inputEl.removeEventListener('keydown', this._onKeydownBound);
  this.inputEl.removeEventListener('focus', this._onFocusBound);
  this.placeholderEl.removeEventListener('click', this._onPlaceholderClickBound);
},

_getCaretStart

method
 _getCaretStart() 

Get the position of the caret in the element.

_getCaretStart: function() {

  return this._maintainFocus(function() {

    var caretPosition;

    // IE support
    if (document.selection) {
      this.inputEl.focus();
      var sel = document.selection.createRange();
      sel.moveStart('character', -this.inputEl.value.length);
      caretPosition = sel.text.length;
    } else if (this.inputEl.selectionStart || this.inputEl.selectionStart === 0) {
      caretPosition = this.inputEl.selectionStart;
    }

    return caretPosition;
  });
},

_getCaretEnd

method
 _getCaretEnd() 

Get the end position of the caret in the element.

_getCaretEnd: function() {

  return this._maintainFocus(function() {

    var caretPosition;

    // IE support - @todo: this doesn't work in IE
    if (document.selection) {
      this.inputEl.focus();
      var sel = document.selection.createRange();
      sel.moveStart('character', -this.inputEl.value.length);
      caretPosition = sel.text.length;
    } else if (this.inputEl.selectionEnd || this.inputEl.selectionEnd === 0) {
      caretPosition = this.inputEl.selectionEnd;
    }

    return caretPosition;
  });
},

_caretIsAtEnd

method
 _caretIsAtEnd() 

Is the caret at the end of the input?

_caretIsAtEnd: function() {
  return this._getCaretStart() === this.maxLength;
},

_setCaretPosition

method
 _setCaretPosition() 

Set the position of the caret in the element.

_setCaretPosition: function(pos) {

  return this._maintainFocus(function() {

    // IE support
    if (document.selection) {
      this.inputEl.focus();
      var sel = document.selection.createRange();
      sel.moveStart('character', -this.inputEl.value.length);
      sel.moveStart('character', pos);
      sel.moveEnd('character', 0);
      sel.select();
    } else if (this.inputEl.selectionStart || this.inputEl.selectionStart === 0) {
      this.inputEl.selectionStart = pos;
      this.inputEl.selectionEnd = pos;
    }
  });
},

_getCaretPositionTranslated

method
 _getCaretPositionTranslated() 

Option name Type Description
pos Number
return Number

Get the position of the caret translated to the corresponding index in the
characters array. This means ignoring format characters.

_getCaretPositionTranslated: function(pos) {

  var i = 0;
  var skipCount = 0;

  for (; i < pos; i++) {

    // Count non-numbers as a skip. @todo: this needs to work with more than numbers.
    if (this.format[i] !== '\\d') {
      skipCount++;
    }
  }

  return pos - skipCount;
},

_getCaretStartTranslated

method
 _getCaretStartTranslated() 

Get the starting position of the caret translated.

_getCaretStartTranslated: function() {
  return this._getCaretPositionTranslated(this._getCaretStart());
},

_getCaretEndTranslated

method
 _getCaretEndTranslated() 

Get the ending position of the caret translated.

_getCaretEndTranslated: function() {
  return this._getCaretPositionTranslated(this._getCaretEnd());
},

_setCaretPositionTranslated

method
 _setCaretPositionTranslated() 

Option name Type Description
pos Number

Set the position of the caret translated to the corresponding index in the
characters array. This means ignoring format characters.

_setCaretPositionTranslated: function(pos) {

  var i = 0;
  var skipCount = 0;

  for (; i < pos + skipCount; i++) {

    // Count non-numbers as a skip. @todo: this needs to work with more than numbers.
    if (this.format[i] !== undefined && this.format[i] !== '\\d') {
      skipCount++;
    }
  }

  this._setCaretPosition(pos + skipCount);
},

_moveCaret

method
 _moveCaret() 

Option name Type Description
direction String

The direction of the movement

return Boolean

Was the caret actually moved?

Move the caret position

_moveCaret: function(direction) {

  var curPos = this._getCaretStart();

  if (direction === 'left') {
    this._setCaretPosition(curPos - 1);
  } else if (direction === 'right') {
    this._setCaretPosition(curPos + 1);
  }

  return curPos !== this._getCaretStart();
},

_emptyWhenOnlyPlaceholders

method
 _emptyWhenOnlyPlaceholders() 

Empty the input when we only have placeholders.

_emptyWhenOnlyPlaceholders: function() {
  if (!this.characters.length) {
    this.clear();
  }
},

_maintainFocus

method
 _maintainFocus() 

Option name Type Description
callback Function

Run a callback function that may change the focus of the document, but
make sure focus goes back to where it needs to be. Also, set the state
so that blur/focus events don't fire from this instance.

_maintainFocus: function(callback) {

  this.pause();

  var originalActiveElement = document.activeElement;

  //For IE
  if(!originalActiveElement) {
    originalActiveElement = document.body;
  }

  var output = (callback || noop).call(this);

  // If we didn't have focus, go back to focusing on the original
  if (originalActiveElement !== this.inputEl) {
    this.inputEl.blur();
    originalActiveElement.focus();
  }

  this.resume();

  return output;
},

_updateWidth

method
 _updateWidth() 

Update the width of the typeahead. If we should be matching the width
of the placeholder, do so. Otherwise, take no action.

_updateWidth: function() {

  if (this.matchPlaceholderSize) {
    this.placeholderEl.style.width = 'auto';
    // Add 2px to account for caret width in IE... @todo: better possible fix?
    this.inputEl.style.width = 'auto';
    this.inputEl.style.width = this.placeholderEl.offsetWidth + 2 + 'px';
    this.placeholderEl.style.width = '';
  }
},

_onKeydown

method
 _onKeydown() 

Option name Type Description
e Object

Listen for delete and arrows.

_onKeydown: function(e) {

  var code = e.keyCode || e.which;

  if (code === this.pasteCode && (e.metaKey || e.ctrlKey)) {
    return;
  }

  if (code === this.actionCodes.BACKSPACE) {
    this.removeCharacterAtCaret(-1);
    this._onBackspace();
    e.preventDefault();
  } else if (code === this.actionCodes.DELETE) {
    this.removeCharacterAtCaret(0);
    e.preventDefault();
  } else if (code === this.actionCodes.LEFT) {
    if (!this._getCaretStart()) {
      (this.onBackspace || noop)();
    }
  } else if (code === this.actionCodes.RIGHT) {
    if (this._getCaretStart() === this.characters.length) {
      (this.onEnd || noop)();
    }
  } else {
    if (this.ignoreCodes.indexOf(code) === -1) {
      e.preventDefault();
      this.addCharacterAtCaret(String.fromCharCode(code));
    }
  }
},

_onInput

method
 _onInput() 

Option name Type Description
e Object

When the input event fires, validate. This happens
with a copy+paste.

_onInput: function(e) {
  e.preventDefault();
  this.characters = this._parseCharacters(this.inputEl.value);
  this.run();
},

_onFocus

method
 _onFocus() 

Option name Type Description
e Object

When we focus, run the formatting.

_onFocus: function() {

  window.removeEventListener('blur', this._onBlurBound, false);
  window.addEventListener('blur', this._onBlurBound, false);
  this.inputEl.removeEventListener('blur', this._onBlurBound, false);
  this.inputEl.addEventListener('blur', this._onBlurBound, false);

  if (this.isFocused || this.pauseBlurFocus || this.isRunning) return;

  this.run();
  (this.onFocus || noop)(this.getValue());
  this.isFocused = true;
  this._oldVal = this.inputEl.value;
},

_onBlur

method
 _onBlur() 

Option name Type Description
e Object

When we blur, if we have no characters, remove the placeholders.

_onBlur: function() {

  window.removeEventListener('blur', this._onBlurBound);
  this.inputEl.removeEventListener('blur', this._onBlurBound);

  this.isFocused = false;

  if (this.pauseBlurFocus || this.isRunning) return;

  this._emptyWhenOnlyPlaceholders();

  // preventDefault will not dispatch change event
  // manually dispatch change event
  if(this._oldVal !== this.inputEl.value) {
    this._triggerEvent(this.inputEl, 'change');
  }

  (this.onBlur || noop)(this.getValue());
},

_onPlaceholderClick

method
 _onPlaceholderClick() 

Option name Type Description
e Object

When the placeholder receives a click event, focus on the input. This happens in IE10 for some
reason that I cannot fully fathom, but it has something to do with the explicit width being
set on an empty element.

_onPlaceholderClick: function(e) {
  e.preventDefault();
  e.stopPropagation();
  this.inputEl.focus();
},

_onBackspace

method
 _onBackspace() 

Option name Type Description
e Object

When we backspace, if we have no characters left let listeners know.

_onBackspace: function() {
  if (!this._getCaretStart())(this.onBackspace || noop)();
}
  };

  Base.exportjQuery(Typeahead, 'Typeahead');

  return Typeahead;
}));