Sabre Spark

Typeahead

Listen to an input element and format it as the user types.

Option name Type Description
module helper/typeahead.js
Example
new Typeahead(el);

Typeahead

function
 Typeahead() 

Typeahead constructor

Option name Type Description
el Element
params Object
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.

Option name Type Description
_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.

Option name Type Description
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() 

Run the formatting. @todo: rename this.

Option name Type Description
cursorIndex Number
params Object
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() 

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

Option name Type Description
character String
start Number
end Number
skipCheck Boolean
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() 

Add a character at the position of the caret.

Option name Type Description
character String
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() 

Remove a character from the character array by index.

Option name Type Description
index Number
length Number

Optional

offset Number

Optional

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() 

Remove the character at the caret.

Option name Type Description
offset Number

Optional An offset from the current position.

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() 

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

Option name Type Description
value String
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() 

Get the value of the typeahead.

Option name Type Description
asInt Boolean

Get the value as a parsed integer.

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

moveCaret

method
 moveCaret() 

Move the caret position.

Option name Type Description
pos Number
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() 

Store a reference to the needed elements.

Option name Type Description
el Object
_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() 

Parse the format string into an array.

Option name Type Description
format String
return 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() 

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

Option name Type Description
characters String
return Array
_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.

Option name Type Description
return 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.

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

_getCharactersAllowedCount

method
 _getCharactersAllowedCount() 

Get the maximum number of characters allowed.

Option name Type Description
format Array
return Number
_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.

Option name Type Description
return Number

The index

_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.

Option name Type Description
return Number

The index

_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?

Option name Type Description
return Boolean
_caretIsAtEnd: function() {
  return this._getCaretStart() === this.maxLength;
},

_setCaretPosition

method
 _setCaretPosition() 

Set the position of the caret in the element.

Option name Type Description
return Number

The index

_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() 

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

Option name Type Description
pos Number
return Number
_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.

Option name Type Description
return Number
_getCaretStartTranslated: function() {
  return this._getCaretPositionTranslated(this._getCaretStart());
},

_getCaretEndTranslated

method
 _getCaretEndTranslated() 

Get the ending position of the caret translated.

Option name Type Description
return Number
_getCaretEndTranslated: function() {
  return this._getCaretPositionTranslated(this._getCaretEnd());
},

_setCaretPositionTranslated

method
 _setCaretPositionTranslated() 

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

Option name Type Description
pos Number
_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() 

Move the caret position

Option name Type Description
direction String

The direction of the movement

return Boolean

Was the caret actually moved?

_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() 

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.

Option name Type Description
callback Function
_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() 

Listen for delete and arrows.

Option name Type Description
e Object
_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() 

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

Option name Type Description
e Object
_onInput: function(e) {
  e.preventDefault();
  this.characters = this._parseCharacters(this.inputEl.value);
  this.run();
},

_onFocus

method
 _onFocus() 

When we focus, run the formatting.

Option name Type Description
e Object
_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() 

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

Option name Type Description
e Object
_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() 

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.

Option name Type Description
e Object
_onPlaceholderClick: function(e) {
  e.preventDefault();
  e.stopPropagation();
  this.inputEl.focus();
},

_onBackspace

method
 _onBackspace() 

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

Option name Type Description
e Object
_onBackspace: function() {
  if (!this._getCaretStart())(this.onBackspace || noop)();
}
  };

  Base.exportjQuery(Typeahead, 'Typeahead');

  return Typeahead;
}));