Thursday, November 7, 2013

Decimal Comma with Numeric Keypad as ADF Client Behavior

According to wikipedia 24% of the world's population uses a comma as decimal separator, including The Netherlands where I live. This means entering numeric information with the numeric keypad on the keyboard can be challenging. You can only use it to type a decimal point (.) while we need a decimal comma (,). To make things ADF simply ignores the decimal point in our locale. When a user enters 1.23 it is converted to 123 and wrong information is entered into the system.
Numeric keypad decimal point

We came up with a solution based on a custom ADF Behavior tag. I've recently posted how to create your own ADF (client side) behavior. The first post was a simple version without properties, while the second post expanded this example with properties. Now it is time for the sequel showing how you can handle key presses in an input item and replace any decimal point keystroke with a decimal separator (possibly comma) keystroke. The user can simply use the decimal point on the numeric keypad and when using a European locale it will simply type a comma.


Read these previous posts to learn how to create custom ADF behavior tags. This post will only focus on the parts that are specific for this solution: the BehaviorTag class, the javascript implementation and a sample page. Let's start with the DecimalCommaBehaviorTag class. This represents the redheap:decimalCommaBehavior JSP tag and constructs the client side snippet of javascript:
package com.redheap.decimalcomma;

import java.text.DecimalFormatSymbols;
import java.util.Locale;
import javax.faces.component.UIComponent;
import javax.faces.context.FacesContext;
import org.apache.commons.lang.StringEscapeUtils;
import org.apache.myfaces.trinidad.context.RequestContext;

/**
 * Implementation of redheap:decimalCommaBehavior JSP tag.
 * @author Wilfred van der Deijl, www.redheap.com
 */
public class DecimalCommaBehaviorTag 
  extends oracle.adfinternal.view.faces.taglib.behaviors.BehaviorTag {

    /**
     * Returns the snippet of javascript code that needs to be executed
     * on the client to construct the client behavior object.
     * @param component JSF component this behavior tag is attached to
     * @return snippet of javascript that instantiates an object
     */
    @Override
    protected String getBehavior(UIComponent component) {
      return "new RedHeapDecimalCommaBehavior('" +
          StringEscapeUtils.escapeJavaScript(getDecimalSeparator()) + 
          "')";
    }

    /**
     * Returns the name of a JavaScript library feature that provides
     * the client-side implementation of this listener/behavior.
     * This feature will be added to the set of features required for
     * this page/request, ensuring that corresponding JavaScript
     * library partition is available.
     * @return a javascript library feature name
     */
    @Override
    protected String getFeatureDependency() {
      return "RedHeapDecimalCommaBehavior";
    }

    protected String getDecimalSeparator() {
      DecimalFormatSymbols dfs = new DecimalFormatSymbols(getLocale());
      return String.valueOf(dfs.getDecimalSeparator());
    }

    protected Locale getLocale() {
      Locale locale =
          RequestContext.getCurrentInstance().getFormattingLocale();
      return locale != null ? locale :
            FacesContext.getCurrentInstance().getViewRoot().getLocale();
    }

}
Line 25-27 returns the javascript script to instantiate the client-side implementation object. This gets the decimal separator character as argument to the constructor. This decimal separator character is determined in the getDecimalSeparator() methods which uses getLocale() to determine the locale for the current request/page.

Next is the most important part of this solution: the client-side javascript implementation:
/**
 * javascript constructor that invokes the Init function
 */
function RedHeapDecimalCommaBehavior(decimalSeparator) {
  this.Init(decimalSeparator);
}

// register as a subclass of AdfClientBehavior
AdfObject.createSubclass(RedHeapDecimalCommaBehavior,
                         AdfClientBehavior);

/**
 * initialize this behavior
 * @override
 */
RedHeapDecimalCommaBehavior.prototype.Init = function(decimalSeparator) {
  // be sure to invoke initialization by superclass
  RedHeapDecimalCommaBehavior.superclass.Init.call(this);
  this._decimalSeparator = decimalSeparator;
}

/**
 * as part of the AdfClientBehavior behavior contract, initialize is
 * called when the component is created to give the behavior a chance
 * to register event listeners.
 * @param {AdfUIComponent} component
 */
RedHeapDecimalCommaBehavior.prototype.initialize =
function(component) {
  AdfAssert.assertPrototype(component, AdfUIComponent);
  component.addEventListener(AdfUIInputEvent.KEY_PRESS_EVENT_TYPE,
                             this._handleKeyPress, this);
}

/**
 * handle invoking a command component with this behavior.
 * @param {AdfUIInputEvent} event
 */
RedHeapDecimalCommaBehavior.prototype._handleKeyPress =
function(event) {
  AdfAssert.assertPrototype(event, AdfUIInputEvent);
  if (event.getKeyCode() == '.'.charCodeAt(0)) {
    // this looks like the '.' is pressed and we might want to replace it
    if (event.getNativeEvent && event.getNativeEvent().charCode==0) {
      // Firefox wrongfully raises this keyPress event on the DELETE
      // button as well. Look at the native event to distinguish and
      // abort. Also see "Special keys" at 
      // http://www.quirksmode.org/dom/events/keys.html
      return;
    }
    if ('.' == this._decimalSeparator) {
      // no need to do anything if decimal-separator of current locale
      // uses decimal dot
      return;
    }
    // replace keystroke of dot on numeric keypad with decimal separator
    // of current locale
    var source = event.getSource();
    AdfLogger.LOGGER.logMessage(AdfLogger.FINE, 
                                "replacing . keystroke with " +
                                this._decimalSeparator + " for " +
                                source.getClientId());
    var input = AdfDhtmlEditableValuePeer.GetContentNode(source);
    if (input) {
      event.cancel(); // cancel normal event to suppress '.' character
      if (typeof(input.selectionStart) !== 'undefined') {
        // gecko, webkit or IE9+ support selection properties
        AdfLogger.LOGGER.logMessage(AdfLogger.FINE, 
          "using selection properties for modern browsers");
        // determine future caret position before we start messing with
        // selection and content
        var newPos = input.selectionStart + this._decimalSeparator.length;
        // inject decimal separator while keeping text before and after
        // selection (thus removing selection)
        var oldtext = input.value;
        input.value = oldtext.substring(0, input.selectionStart) + 
                      this._decimalSeparator +
                      oldtext.substring(input.selectionEnd, 
                                        oldtext.length);
        // set caret position after the decimal separator
        input.selectionStart = newPos;
        input.selectionEnd = newPos;
      } else if (document.selection && document.selection.createRange) {
        AdfLogger.LOGGER.logMessage(AdfLogger.FINE, 
          "using document.selection for older IE versions");
        var sel = document.selection.createRange();
        // replace current selection with decimal-sep or insert at caret
        sel.text = this._decimalSeparator; 
        // collapse selection so caret is after decimal-separator
        sel.setEndPoint("StartToEnd", sel); 
        sel.select();
      }
    }
  }
}

The code should be well documented so reading a couple of times should make clear what it does. Basically we are canceling the key-press event for "." and injecting the decimal separator in the value of the current item at the cursor position or replacing any existing selected text within this item.

Finally the sample page:
<?xml version='1.0' encoding='UTF-8'?>
<jsp:root xmlns:jsp="http://java.sun.com/JSP/Page" version="2.1"
          xmlns:f="http://java.sun.com/jsf/core"
          xmlns:h="http://java.sun.com/jsf/html"
          xmlns:af="http://xmlns.oracle.com/adf/faces/rich"
          xmlns:redheap="http://redheap.com/taglib">
  <jsp:directive.page contentType="text/html;charset=UTF-8"/>
  <f:view>
    <af:document id="d1">
      <af:form id="f1">
        <af:panelFormLayout>
          <af:inputText label="Browser locale (should use comma decimal separator)"
                        value="#{facesContext.viewRoot.locale}" readOnly="true"/>
          <af:inputText label="Try typing point on decimal keyboard" id="it1">
            <redheap:decimalCommaBehvior/>
          </af:inputText>
        </af:panelFormLayout>
      </af:form>
    </af:document>
  </f:view>
</jsp:root>

As always you can download the full sample application or browse the subversion repository to look at the source code.