Skip to content

Instantly share code, notes, and snippets.

@lstone
Created October 6, 2015 18:27
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lstone/a952e169090dc3c64594 to your computer and use it in GitHub Desktop.
Save lstone/a952e169090dc3c64594 to your computer and use it in GitHub Desktop.
ui.mask w/ custom placeholder options (focused vs unfocused field)
###*
# @ngdoc directive
# @name ui.mask.directive:uiMask
# @restrict A
# @scope
# @description
# Attaches input mask onto input element. NOTE: This is a custom version the ui-mask plugin that addresses the issue of the placeholder attribute
# overwritten rather than being displayed until the user focuses into the input field. Credit goes to: https://github.com/qwyzyx
#
# @param {String} ui-mask - The keys in maskDefinitions represent the special tokens/characters used in your mask declaration to delimit acceptable ranges of inputs. For example, we use '9' here to accept any numeric values for a phone number: ui-mask="(999) 999-9999". The values associated with each token are regexen. Each regex defines the ranges of values that will be acceptable as inputs in the position of that token.
# @param {String} ui-mask-placeholder - what to show when the field is focused (focused)
# @param {String} placeholder - adds a regular placeholder to the input field (unfocused)
# @example
<input type="tel" ui-mask-placeholder=" - - " placeholder="Phone number" ui-mask="999-999-9999">
###
angular.module('ui.mask', []).value('uiMaskConfig',
'maskDefinitions':
'9': /\d/
'A': /[a-zA-Z]/
'*': /[a-zA-Z0-9]/
'clearOnBlur': true).directive 'uiMask', [
'uiMaskConfig'
'$parse'
(maskConfig, $parse) ->
'use strict'
{
priority: 100
require: 'ngModel'
restrict: 'A'
compile: ->
options = maskConfig
(scope, iElement, iAttrs, controller) ->
maskProcessed = false
eventsBound = false
maskCaretMap = undefined
maskPatterns = undefined
maskPlaceholder = undefined
maskComponents = undefined
minRequiredLength = undefined
value = undefined
valueMasked = undefined
isValid = undefined
originalPlaceholder = iAttrs.placeholder
originalMaxlength = iAttrs.maxlength
oldValue = undefined
oldValueUnmasked = undefined
oldCaretPosition = undefined
oldSelectionLength = undefined
linkOptions = {}
initialize = (maskAttr) ->
if !angular.isDefined(maskAttr)
return uninitialize()
processRawMask maskAttr
if !maskProcessed
return uninitialize()
initializeElement()
bindEventListeners()
true
initPlaceholder = (placeholderAttr) ->
if !angular.isDefined(placeholderAttr)
return
maskPlaceholder = placeholderAttr
# If the mask is processed, then we need to update the value
if maskProcessed
eventHandler()
return
formatter = (fromModelValue) ->
if !maskProcessed
return fromModelValue
value = unmaskValue(fromModelValue or '')
isValid = validateValue(value)
controller.$setValidity 'mask', isValid
if isValid and value.length then maskValue(value) else undefined
parser = (fromViewValue) ->
if !maskProcessed
return fromViewValue
value = unmaskValue(fromViewValue or '')
isValid = validateValue(value)
# We have to set viewValue manually as the reformatting of the input
# value performed by eventHandler() doesn't happen until after
# this parser is called, which causes what the user sees in the input
# to be out-of-sync with what the controller's $viewValue is set to.
controller.$viewValue = if value.length then maskValue(value) else ''
controller.$setValidity 'mask', isValid
if value == '' and iAttrs.required
controller.$setValidity 'required', !controller.$error.required
if isValid then value else undefined
uninitialize = ->
maskProcessed = false
unbindEventListeners()
if angular.isDefined(originalPlaceholder)
iElement.attr 'placeholder', originalPlaceholder
else
iElement.removeAttr 'placeholder'
if angular.isDefined(originalMaxlength)
iElement.attr 'maxlength', originalMaxlength
else
iElement.removeAttr 'maxlength'
iElement.val controller.$modelValue
controller.$viewValue = controller.$modelValue
false
initializeElement = ->
value = oldValueUnmasked = unmaskValue(controller.$modelValue or '')
valueMasked = oldValue = maskValue(value)
isValid = validateValue(value)
viewValue = if isValid and value.length then valueMasked else ''
if iAttrs.maxlength
# Double maxlength to allow pasting new val at end of mask
iElement.attr 'maxlength', maskCaretMap[maskCaretMap.length - 1] * 2
if !iAttrs.uiMaskPlaceholder
iElement.attr 'placeholder', maskPlaceholder
iElement.val viewValue
controller.$viewValue = viewValue
# Not using $setViewValue so we don't clobber the model value and dirty the form
# without any kind of user interaction.
return
bindEventListeners = ->
if eventsBound
return
iElement.bind 'blur', blurHandler
iElement.bind 'mousedown mouseup', mouseDownUpHandler
iElement.bind 'input keyup click focus', eventHandler
eventsBound = true
return
unbindEventListeners = ->
if !eventsBound
return
iElement.unbind 'blur', blurHandler
iElement.unbind 'mousedown', mouseDownUpHandler
iElement.unbind 'mouseup', mouseDownUpHandler
iElement.unbind 'input', eventHandler
iElement.unbind 'keyup', eventHandler
iElement.unbind 'click', eventHandler
iElement.unbind 'focus', eventHandler
eventsBound = false
return
validateValue = (value) ->
# Zero-length value validity is ngRequired's determination
if value.length then value.length >= minRequiredLength else true
unmaskValue = (value) ->
valueUnmasked = ''
maskPatternsCopy = maskPatterns.slice()
# Preprocess by stripping mask components from value
value = value.toString()
angular.forEach maskComponents, (component) ->
value = value.replace(component, '')
return
angular.forEach value.split(''), (chr) ->
if maskPatternsCopy.length and maskPatternsCopy[0].test(chr)
valueUnmasked += chr
maskPatternsCopy.shift()
return
valueUnmasked
maskValue = (unmaskedValue) ->
`var valueMasked`
valueMasked = ''
maskCaretMapCopy = maskCaretMap.slice()
angular.forEach maskPlaceholder.split(''), (chr, i) ->
if unmaskedValue.length and i == maskCaretMapCopy[0]
valueMasked += unmaskedValue.charAt(0) or '_'
unmaskedValue = unmaskedValue.substr(1)
maskCaretMapCopy.shift()
else
valueMasked += chr
return
valueMasked
getPlaceholderChar = (i) ->
placeholder = if iAttrs.uiMaskPlaceholder then iAttrs.uiMaskPlaceholder else iAttrs.placeholder
if typeof placeholder != 'undefined' and placeholder[i]
placeholder[i]
else
'_'
# Generate array of mask components that will be stripped from a masked value
# before processing to prevent mask components from being added to the unmasked value.
# E.g., a mask pattern of '+7 9999' won't have the 7 bleed into the unmasked value.
# If a maskable char is followed by a mask char and has a mask
# char behind it, we'll split it into it's own component so if
# a user is aggressively deleting in the input and a char ahead
# of the maskable char gets deleted, we'll still be able to strip
# it in the unmaskValue() preprocessing.
getMaskComponents = ->
maskPlaceholder.replace(/[_]+/g, '_').replace(/([^_]+)([a-zA-Z0-9])([^_])/g, '$1$2_$3').split '_'
processRawMask = (mask) ->
characterCount = 0
maskCaretMap = []
maskPatterns = []
maskPlaceholder = ''
if typeof mask == 'string'
minRequiredLength = 0
isOptional = false
splitMask = mask.split('')
angular.forEach splitMask, (chr, i) ->
if linkOptions.maskDefinitions[chr]
maskCaretMap.push characterCount
maskPlaceholder += getPlaceholderChar(i)
maskPatterns.push linkOptions.maskDefinitions[chr]
characterCount++
if !isOptional
minRequiredLength++
else if chr == '?'
isOptional = true
else
maskPlaceholder += chr
characterCount++
return
# Caret position immediately following last position is valid.
maskCaretMap.push maskCaretMap.slice().pop() + 1
maskComponents = getMaskComponents()
maskProcessed = if maskCaretMap.length > 1 then true else false
return
blurHandler = ->
if linkOptions.clearOnBlur
oldCaretPosition = 0
oldSelectionLength = 0
if !isValid or value.length == 0
if linkOptions.clearOnBlur
valueMasked = ''
iElement.val ''
scope.$apply ->
controller.$setViewValue ''
return
return
mouseDownUpHandler = (e) ->
if e.type == 'mousedown'
iElement.bind 'mouseout', mouseoutHandler
else
iElement.unbind 'mouseout', mouseoutHandler
return
mouseoutHandler = ->
###jshint validthis: true ###
oldSelectionLength = getSelectionLength(this)
iElement.unbind 'mouseout', mouseoutHandler
return
eventHandler = (e) ->
###jshint validthis: true ###
e = e or {}
# Allows more efficient minification
eventWhich = e.which
eventType = e.type
# Prevent shift and ctrl from mucking with old values
if eventWhich == 16 or eventWhich == 91
return
val = iElement.val()
valOld = oldValue
valMasked = undefined
valUnmasked = unmaskValue(val)
valUnmaskedOld = oldValueUnmasked
valAltered = false
caretPos = getCaretPosition(this) or 0
caretPosOld = oldCaretPosition or 0
caretPosDelta = caretPos - caretPosOld
caretPosMin = maskCaretMap[0]
caretPosMax = maskCaretMap[valUnmasked.length] or maskCaretMap.slice().shift()
selectionLenOld = oldSelectionLength or 0
isSelected = getSelectionLength(this) > 0
wasSelected = selectionLenOld > 0
isAddition = val.length > valOld.length or selectionLenOld and val.length > valOld.length - selectionLenOld
isDeletion = val.length < valOld.length or selectionLenOld and val.length == valOld.length - selectionLenOld
isSelection = eventWhich >= 37 and eventWhich <= 40 and e.shiftKey
isKeyLeftArrow = eventWhich == 37
isKeyBackspace = eventWhich == 8 or eventType != 'keyup' and isDeletion and caretPosDelta == -1
isKeyDelete = eventWhich == 46 or eventType != 'keyup' and isDeletion and caretPosDelta == 0 and !wasSelected
caretBumpBack = (isKeyLeftArrow or isKeyBackspace or eventType == 'click') and caretPos > caretPosMin
oldSelectionLength = getSelectionLength(this)
# These events don't require any action
if isSelection or isSelected and (eventType == 'click' or eventType == 'keyup')
return
# Value Handling
# ==============
# User attempted to delete but raw value was unaffected--correct this grievous offense
if eventType == 'input' and isDeletion and !wasSelected and valUnmasked == valUnmaskedOld
while isKeyBackspace and caretPos > caretPosMin and !isValidCaretPosition(caretPos)
caretPos--
while isKeyDelete and caretPos < caretPosMax and maskCaretMap.indexOf(caretPos) == -1
caretPos++
charIndex = maskCaretMap.indexOf(caretPos)
# Strip out non-mask character that user would have deleted if mask hadn't been in the way.
valUnmasked = valUnmasked.substring(0, charIndex) + valUnmasked.substring(charIndex + 1)
valAltered = true
# Update values
valMasked = maskValue(valUnmasked)
oldValue = valMasked
oldValueUnmasked = valUnmasked
iElement.val valMasked
if valAltered
# We've altered the raw value after it's been $digest'ed, we need to $apply the new value.
scope.$apply ->
controller.$setViewValue valUnmasked
return
# Caret Repositioning
# ===================
# Ensure that typing always places caret ahead of typed character in cases where the first char of
# the input is a mask char and the caret is placed at the 0 position.
if isAddition and caretPos <= caretPosMin
caretPos = caretPosMin + 1
if caretBumpBack
caretPos--
# Make sure caret is within min and max position limits
caretPos = if caretPos > caretPosMax then caretPosMax else if caretPos < caretPosMin then caretPosMin else caretPos
# Scoot the caret back or forth until it's in a non-mask position and within min/max position limits
while !isValidCaretPosition(caretPos) and caretPos > caretPosMin and caretPos < caretPosMax
caretPos += if caretBumpBack then -1 else 1
if caretBumpBack and caretPos < caretPosMax or isAddition and !isValidCaretPosition(caretPosOld)
caretPos++
oldCaretPosition = caretPos
setCaretPosition this, caretPos
return
isValidCaretPosition = (pos) ->
maskCaretMap.indexOf(pos) > -1
getCaretPosition = (input) ->
if !input
return 0
if input.selectionStart != undefined
return input.selectionStart
else if document.selection
# Curse you IE
input.focus()
selection = document.selection.createRange()
selection.moveStart 'character', if input.value then -input.value.length else 0
return selection.text.length
0
setCaretPosition = (input, pos) ->
if !input
return 0
if input.offsetWidth == 0 or input.offsetHeight == 0
return
# Input's hidden
if input.setSelectionRange
input.focus()
input.setSelectionRange pos, pos
else if input.createTextRange
# Curse you IE
range = input.createTextRange()
range.collapse true
range.moveEnd 'character', pos
range.moveStart 'character', pos
range.select()
return
getSelectionLength = (input) ->
if !input
return 0
if input.selectionStart != undefined
return input.selectionEnd - (input.selectionStart)
if document.selection
return document.selection.createRange().text.length
0
if iAttrs.uiOptions
linkOptions = scope.$eval('[' + iAttrs.uiOptions + ']')
if angular.isObject(linkOptions[0])
# we can't use angular.copy nor angular.extend, they lack the power to do a deep merge
linkOptions = ((original, current) ->
for i of original
if Object::hasOwnProperty.call(original, i)
if current[i] == undefined
current[i] = angular.copy(original[i])
else
angular.extend current[i], original[i]
current
)(options, linkOptions[0])
else
linkOptions = options
iAttrs.$observe 'uiMask', initialize
if iAttrs.uiMaskPlaceholder
iAttrs.$observe 'uiMaskPlaceholder', initPlaceholder
else
iAttrs.$observe 'placeholder', initPlaceholder
modelViewValue = false
iAttrs.$observe 'modelViewValue', (val) ->
if val == 'true'
modelViewValue = true
return
scope.$watch iAttrs.ngModel, (val) ->
if modelViewValue and val
model = $parse(iAttrs.ngModel)
model.assign scope, controller.$viewValue
return
controller.$formatters.push formatter
controller.$parsers.push parser
iElement.bind 'mousedown mouseup', mouseDownUpHandler
# https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/indexOf
if !Array::indexOf
Array::indexOf = (searchElement) ->
if this == null
throw new TypeError
t = Object(this)
len = t.length >>> 0
if len == 0
return -1
n = 0
if arguments.length > 1
n = Number(arguments[1])
if n != n
# shortcut for verifying if it's NaN
n = 0
else if n != 0 and n != Infinity and n != -Infinity
n = (n > 0 or -1) * Math.floor(Math.abs(n))
if n >= len
return -1
k = if n >= 0 then n else Math.max(len - Math.abs(n), 0)
while k < len
if k of t and t[k] == searchElement
return k
k++
-1
return
}
]
@lstone
Copy link
Author

lstone commented Oct 6, 2015

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment