Spark Masked Text Input Component. Second Draft (near 1.0 version)
This file contains 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
//////////////////////////////////////////////////////////////////////////////// | |
// | |
// Licensed to the Apache Software Foundation (ASF) under one or more | |
// contributor license agreements. See the NOTICE file distributed with | |
// this work for additional information regarding copyright ownership. | |
// The ASF licenses this file to You under the Apache License, Version 2.0 | |
// (the "License"); you may not use this file except in compliance with | |
// the License. You may obtain a copy of the License at | |
// | |
// http://www.apache.org/licenses/LICENSE-2.0 | |
// | |
// Unless required by applicable law or agreed to in writing, software | |
// distributed under the License is distributed on an "AS IS" BASIS, | |
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
// See the License for the specific language governing permissions and | |
// limitations under the License. | |
// | |
//////////////////////////////////////////////////////////////////////////////// | |
package spark.components { | |
import flash.events.Event; | |
import flash.events.TextEvent; | |
import flashx.textLayout.edit.SelectionState; | |
import flashx.textLayout.operations.CompositeOperation; | |
import flashx.textLayout.operations.DeleteTextOperation; | |
import flashx.textLayout.operations.InsertTextOperation; | |
import mx.core.mx_internal; | |
import mx.utils.StringUtil; | |
import spark.components.RichEditableText; | |
import spark.components.TextInput; | |
import spark.events.TextOperationEvent; | |
use namespace mx_internal; | |
/** | |
* Masked Text Input Component | |
* | |
* A TextInput extension with a mask text and a string pattern of separators that constraints the text | |
* introduced by the user to the available characters dictated by the pattern and inserts or deletes | |
* separator characters guided by the mask text pattern. | |
* | |
* This control is widely used in applications to insert different kind of data like dates, | |
* bank accounts, plates or phone numbers... | |
* | |
* Template rules: | |
* | |
* # - numeric-only, | |
* @ - alphabetic-only, | |
* ? - any | |
* | |
* configuration properties: | |
* | |
* maskText : The mask base string for the component logic | |
* defaults to "" | |
* separators : The characters specified to act as separators that must be present in the maskText | |
* defaults to "-+/|()[]{}." | |
* textMaskPrompt : User defined prompt to override default behaviour. | |
* defaults to "" | |
* placeHolder : A character to show instead internal mask template characters (#, @ and ?) | |
* defaults to "_" | |
* usePlaceHolder : If true, show placeHolder instead #, @ or ?, if false show real characters | |
* defaults to true | |
* hideSeparatorInText : If true, separator is hidden in text but shown in mask, viceversa if false. | |
* defaults to true | |
* showMaskWhileWrite : If true the remaining mask is shown while user write as a helper. false | |
* hide mask as soon as the user interact with the control | |
* defaults to true | |
* | |
* Some sample mask patterns: | |
* | |
* Date: ##/##/#### | |
* Phone: (###)###.##.##.## | |
* IBAN: ES##-####-####-##-########## | |
* CCC: ####-####-##-########## | |
* | |
* @author Carlos Rovira (http://www.carlosrovira.com) | |
* @created 05/12/13 | |
* @updated 21/01/14 | |
*/ | |
public class MaskedTextInput extends TextInput { | |
public function MaskedTextInput() { | |
super(); | |
addEventListener(TextOperationEvent.CHANGING, verifyInsertedText); | |
addEventListener(TextOperationEvent.CHANGE, formatAsYouType); | |
addEventListener(TextEvent.TEXT_INPUT, overrideText); | |
} | |
//---------------------------------- | |
// PUBLIC | |
//---------------------------------- | |
//---------------------------------- | |
// text | |
//---------------------------------- | |
private var textChanged:Boolean = false; | |
/** | |
* get text formated with separators | |
*/ | |
public function get fullText():String { | |
return super.text; | |
} | |
/** | |
* get the raw text removing separators | |
*/ | |
override public function get text():String { | |
var rawText:String = ""; | |
for (var fullIndex:int = 0; fullIndex < fullText.length; fullIndex++) { | |
var fullChar:String = fullText.charAt(fullIndex); | |
for (var sepIndex:int = 0; sepIndex < separators.length; sepIndex++) { | |
var sepChar:String = separators.charAt(sepIndex); | |
var found:Boolean = false; | |
if (fullChar == sepChar) { | |
found = true; | |
break; | |
} | |
} | |
if (!found) { | |
rawText += fullChar; | |
} | |
} | |
return rawText; | |
} | |
private var storean:int = 0; | |
private var storeac:int = 0; | |
/** | |
* program a format of the entered text based on mask text and separators | |
* @param value | |
*/ | |
[Bindable("change")] | |
[Bindable("textChanged")] | |
// Compiler will strip leading and trailing whitespace from text string. | |
[CollapseWhiteSpace] | |
override public function set text(value:String):void { | |
if (super.text !== value) { | |
storean = selectionAnchorPosition; | |
storeac = selectionActivePosition; | |
super.text = value; | |
textChanged = true; | |
invalidateProperties(); | |
} | |
} | |
//---------------------------------- | |
// mask text | |
//---------------------------------- | |
private var _maskText:String = ""; | |
private var maskedTextChanged:Boolean = true; | |
[Bindable] | |
public function get maskText():String { | |
return _maskText; | |
} | |
public function set maskText(value:String):void { | |
if (maskText !== value) { | |
_maskText = value; | |
maskedTextChanged = true; | |
invalidateProperties(); | |
} | |
} | |
//---------------------------------- | |
// separators | |
//---------------------------------- | |
private var _separators:String = "- +/|()[]{}."; | |
private var separatorsChanged:Boolean = true; | |
[Bindable] | |
public function get separators():String { | |
return _separators; | |
} | |
public function set separators(value:String):void { | |
if (separators !== value) { | |
separatorsChanged = true; | |
_separators = value; | |
invalidateProperties(); | |
} | |
} | |
/** | |
* user defined text prompt. For example for date mask ("##/##/####") you could use textMaskPrompt "dd/mm/yyyy" | |
*/ | |
public var textMaskPrompt:String = ""; | |
/** | |
* the character to represent a input character location | |
*/ | |
[Bindable] | |
public var placeHolder:String = "_"; | |
/** | |
* substitute the prompt with the selected place holder for all special characters (#,@ and ?) | |
*/ | |
[Bindable] | |
public var usePlaceHolder:Boolean = true; | |
/** | |
* use blank character instead of separator character | |
*/ | |
[Bindable] | |
public var hideSeparatorInText:Boolean = true; | |
/** | |
* show the mask text while user writes, removing characters as user types | |
*/ | |
private var _showMaskWhileWrite:Boolean = true; | |
[Bindable] | |
public function get showMaskWhileWrite():Boolean { | |
return _showMaskWhileWrite; | |
} | |
public function set showMaskWhileWrite(value:Boolean):void { | |
_showMaskWhileWrite = value; | |
invalidateSkinState(); | |
} | |
//---------------------------------- | |
// PRIVATE | |
//---------------------------------- | |
/** | |
* internal character to represent blank space in text | |
*/ | |
private static const BLANK_SEPARATOR:String = " "; | |
/** | |
* an internal array to maintain the insertion point of separators. This array is filled based on the | |
* mask text pattern and the separators available | |
*/ | |
private var separatorLocations:Array = null; | |
//---------------------------------- | |
// COMPONENT LIFE CYCLE | |
//---------------------------------- | |
/** | |
* getCurrentSkinState | |
* @return the new state | |
*/ | |
override protected function getCurrentSkinState():String { | |
if (showMaskWhileWrite) { | |
if (enabled && skin && skin.hasState("normalWithPrompt")) | |
return "normalWithPrompt"; | |
if (!enabled && skin && skin.hasState("disabledWithPrompt")) | |
return "disabledWithPrompt"; | |
} | |
return super.getCurrentSkinState(); | |
} | |
/** | |
* commit properties | |
*/ | |
override protected function commitProperties():void { | |
super.commitProperties(); | |
if (maskedTextChanged) { | |
typicalText = maskText; | |
maxChars = maskText.length; | |
} | |
if (separatorsChanged || maskedTextChanged) { | |
// create the array of separator locations based on mask text and available separators | |
separatorLocations = []; | |
for (var maskIndex:int = 0; maskIndex < maskText.length; maskIndex++) { | |
var maskChar:String = getMaskCharAt(maskIndex); | |
for (var sepIndex:int = 0; sepIndex < separators.length; sepIndex++) { | |
var sepChar:String = separators.charAt(sepIndex); | |
if (maskChar == sepChar) { | |
separatorLocations.push(maskIndex); | |
} | |
} | |
} | |
} | |
if (textChanged) { | |
selectAll(); | |
insertText(formatTextWithMask(text)); | |
selectRange(storean, storeac); | |
} | |
separatorsChanged = maskedTextChanged = textChanged = false; | |
updatePrompt(); | |
} | |
//---------------------------------- | |
// PROTECTED | |
//---------------------------------- | |
/** | |
* format programmatic text (not introduced by user) in textDisplay control with the mask | |
* (i.e.: assigned string to text property, trigger, binding, ...) | |
*/ | |
protected function formatTextWithMask(value:String):String { | |
var stack:Array = value.split(""); | |
var outputText:String = ""; | |
for (var i:int = 0; i < maskText.length; i++) { | |
if (stack.length == 0) { | |
break; | |
} | |
if (isSeparator(i)) {//if is separator location add separator | |
outputText += getMaskCharAt(i); | |
} else { // if not add the expected value char | |
outputText += restrictToMaskPattern(stack.shift(), i); | |
} | |
} | |
return outputText; | |
} | |
/** | |
* verify insertion to avoid characters not allowed in mask | |
* @param event the TextOperationEvent event | |
*/ | |
protected function verifyInsertedText(event:TextOperationEvent):void { | |
// filter now allowed characters | |
var an:int = selectionAnchorPosition; | |
var insertTextOp:InsertTextOperation = null; | |
if (event.operation is InsertTextOperation && an != maxChars) { | |
insertTextOp = event.operation as InsertTextOperation; | |
if (restrictToMaskPattern(insertTextOp.text, an) == "" | |
|| (restrictToMaskPattern(insertTextOp.text, an + 1) == "" && isMaskSeparatorLocation(an))) { | |
event.preventDefault(); | |
} | |
} | |
} | |
/** | |
* add or remove separator character as we type in the text input. | |
* Note that override text when all characters are in place | |
* is not supported (see overrideText method) | |
* @param event the TextOperationEvent event | |
*/ | |
protected function formatAsYouType(event:TextOperationEvent):void { | |
var stack:Array = text.split(""); | |
var outputText:String = ""; | |
var an:int = selectionAnchorPosition; | |
var ac:int = selectionAnchorPosition; | |
var offset:int = 0;//caret advances one position by default (on insert and deletion) | |
//copy/paste | |
if (event.operation is CompositeOperation) { | |
var operations:Array = (event.operation as CompositeOperation).operations; | |
if (operations[1] is InsertTextOperation) { | |
outputText = formatTextWithMask((operations[1] as InsertTextOperation).text); | |
an = ac = outputText.length; | |
} | |
} | |
//insert | |
else if (event.operation is InsertTextOperation) { | |
if ((event.operation as InsertTextOperation).deleteSelectionState != null) { | |
//OVERRIDING INSERT | |
if (ac < maxChars && isSeparator(ac)) { | |
outputText = super.text.substring(0, ac + 1) + (event.operation as InsertTextOperation).text + super.text.substring(ac + 2); | |
} else { | |
outputText = super.text.substring(0, ac) + (event.operation as InsertTextOperation).text + super.text.substring(ac + 1); | |
} | |
if (isSeparator(ac)) { | |
an = an + 2; | |
ac = ac + 2; | |
} | |
else { | |
an = an + 1; | |
ac = ac + 1; | |
} | |
} else { | |
for (var i:int = 0; i < maskText.length; i++) { | |
if (stack.length == 0) { | |
break; | |
} | |
if (isMaskSeparatorLocation(i)) { | |
outputText += getMaskCharAt(i); | |
offset += 1; | |
} else { | |
outputText += restrictToMaskPattern(stack.shift(), i); | |
} | |
} | |
//on override caret does not advance | |
if (super.text.length > an) { | |
//override on separator | |
if (isSeparator(an - 1)) { | |
offset = getDeletePosition(an) + 1; | |
} | |
else if (isSeparator(an)) { | |
offset = 1; | |
} | |
else { | |
offset = 0; | |
} | |
} | |
an = ac = selectionAnchorPosition + offset; | |
} | |
} | |
//delete | |
else if (event.operation is DeleteTextOperation) { | |
if (isSeparator(an - 1)) { | |
offset = consecutiveSeparators(an - 1); | |
} | |
else if (isSeparator(an)) { | |
stack.splice(an - getDeletePosition(an) - 1, 1); | |
offset = 1; | |
} | |
for (i = 0; i < maskText.length; i++) { | |
if (stack.length == 0) { | |
break; | |
} | |
if (isMaskSeparatorLocation(i)) { | |
outputText += getMaskCharAt(i); | |
} else { | |
outputText += restrictToMaskPattern(stack.shift(), i); | |
} | |
} | |
an = ac = selectionActivePosition - offset; | |
} | |
selectAll(); | |
insertText(outputText); | |
selectRange(an, ac); | |
updatePrompt(); | |
dispatchEvent(new Event("textChanged")); | |
} | |
/** | |
* used when text exist and is as long as maxChars and cursor is not at | |
* the end of the text. | |
* overrides the actual text as we type (replacing text as we type) | |
* @param event the TextEvent | |
*/ | |
protected function overrideText(event:TextEvent):void { | |
var an:int = selectionAnchorPosition; | |
var ac:int = selectionActivePosition; | |
// text is full, overwrite characters | |
if (super.text.length == maxChars) { | |
// filter now allowed characters | |
if (restrictToMaskPattern(event.text, an) == "" | |
|| (restrictToMaskPattern(event.text, an + 1) == "" && isMaskSeparatorLocation(an))) { | |
return; | |
} | |
var operationState:SelectionState = new SelectionState(RichEditableText(textDisplay).textFlow, an, ac + 1); | |
var operation:InsertTextOperation = new InsertTextOperation(operationState, event.text, operationState); | |
var changeEvent:TextOperationEvent = new TextOperationEvent(TextOperationEvent.CHANGE, false, true, operation); | |
dispatchEvent(changeEvent); | |
} | |
} | |
//---------------------------------- | |
// PRIVATE | |
//---------------------------------- | |
/** | |
* update prompt | |
*/ | |
private function updatePrompt():void { | |
var _prompt:String = ""; | |
var textLength:int = super.text.length; | |
for (var i:int = 0; i < textLength; i++) { | |
if (isSeparator(i)) {//if is separator location add separator | |
_prompt += hideSeparatorInText ? getMaskCharAt(i) : BLANK_SEPARATOR; | |
} else { // if not add the expected value char | |
_prompt += BLANK_SEPARATOR; | |
} | |
} | |
_prompt += textMaskPrompt == "" ? maskText.substring(textLength) : textMaskPrompt.substring(textLength); | |
if (usePlaceHolder && textMaskPrompt == "") { | |
_prompt = _prompt.replace(/#/g, placeHolder); | |
_prompt = _prompt.replace(/@/g, placeHolder); | |
_prompt = _prompt.replace(/?/g, placeHolder); | |
} | |
prompt = _prompt; | |
} | |
/** | |
* Manage template rules: | |
* # - numeric-only, | |
* @ - alphabetic-only, | |
* ? - any | |
* @param inputChar | |
* @param position | |
* @return the restricted character or the mask character | |
*/ | |
private function restrictToMaskPattern(inputChar:String, position:int):String { | |
var maskChar:String = getMaskCharAt(position); | |
switch (maskChar) { | |
case "#": | |
return StringUtil.restrict(inputChar, "0-9"); | |
case "@": | |
return StringUtil.restrict(inputChar, "a-zA-Z"); | |
case "?": | |
return inputChar; | |
} | |
return maskChar; | |
} | |
//---------------------------------- | |
// PRIVATE AUTOMATA'S LOGIC | |
//---------------------------------- | |
/** | |
* get the mask char at index location | |
* @param index | |
* @return | |
*/ | |
private function getMaskCharAt(index:int):String { | |
return maskText.charAt(index); | |
} | |
/** | |
* | |
* @param index | |
* @return true if location in maskText is separator, false if is placeHolder location | |
*/ | |
private function isMaskSeparatorLocation(index:int):Boolean { | |
for (var sepIndex:int = 0; sepIndex < separators.length; sepIndex++) { | |
var sepChar:String = separators.charAt(sepIndex); | |
if (getMaskCharAt(index) == sepChar) { | |
return true; | |
} | |
} | |
return false; | |
} | |
/** | |
* @param index | |
* @return true if there is separator at index, false otherwise | |
*/ | |
private function isSeparator(index:int):Boolean { | |
for (var i:int = 0; i < separatorLocations.length; i++) { | |
if (index == separatorLocations[i]) { | |
return true; | |
} | |
} | |
return false; | |
} | |
/** | |
* @param index | |
* @return return value to rest from anchor to splice chars when remove, 0 otherwise. | |
*/ | |
private function getDeletePosition(index:int):int { | |
for (var i:int = 0; i < separatorLocations.length; i++) { | |
if (index == separatorLocations[i]) { | |
return i; | |
} | |
} | |
return 0; | |
} | |
/** | |
* @param index | |
* @return return value to rest from anchor to splice chars when remove, 0 otherwise. | |
*/ | |
private function consecutiveSeparators(index:int):int { | |
if (isSeparator(index - 1)) { | |
return 1 + consecutiveSeparators(index - 1); | |
} else { | |
return 1; | |
} | |
return 0; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment