Created
October 6, 2015 18:27
-
-
Save lstone/a952e169090dc3c64594 to your computer and use it in GitHub Desktop.
ui.mask w/ custom placeholder options (focused vs unfocused field)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
###* | |
# @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 | |
} | |
] |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Source and credit: https://github.com/angular-ui/ui-utils/pull/353/commits