twitter/Typeahead.js を Wicket の AJAX として Autocomplete の振るまいにする

WicketAutocomplete といえば、jQuery UI を利用したものが慣れ親しんでものであった。
最近よく使われる Bootstrap デザインを適用しても使えないわけではない、
CSSスタイルシートを合わせて書いていけば良いのだが、
Bootstrap と jQuery UI を併用が嫌で jQuery UI をやめたくなる。
datepicker も Bootstrap向けが存在するわけだし、
Draggable / Drop これさえ Bootstrap でできれば、
jQuery UI を完全にやめれるのではないか?

前置きはこのくらいで、本題!
typeahead.js/jquery_typeahead.md at master · twitter/typeahead.js · GitHub

twitter/typeahead.js が Bootstrap デザインと相性が良いようだ。

まず、twitter/Typeahead.js のリモートからマッチ候補を受信する書き方を把握しておく。。
JSソースとしては、
<input type="text" id="typeahead"> に対して

$('input#typeahead').typeahead({
   highlight: true,
   minLength: 1
}, {
   //name : 'states',
   displayKey: 'display',
   limit: 7,
   source : new Bloodhound({
      datumTokenizer: function(datum){
         return Bloodhound.tokenizers.whitespace(datum.display);
      },
      queryTokenizer: Bloodhound.tokenizers.whitespace,
      remote:{
         wildcard: '%QUERY',
         url: 'http://xxxxxxxxxxxxx?query=%QUERY',
         transform: function(response){
            return $.map(response, function(item){
               return { display:item.value, id:item.id };
            });
         },
      }
   })
}).on('typeahead:select', function(ev, item){
     console.log('選択された id = ' + item.id);
     console.log('選択された 表示文字列 = ' + item.display);
}).on('change', function(ev){
     console.log($(this).val());
});

このようなJSソースをリソースとして用意するのではなく、
Wicket TextField の振るまい Behavior として
AJAX通信→ マッチ候補リスト表示させるようにするのです。

CSSは、以下に適当なものがあったので、
https://github.com/bassjobsen/typeahead.js-bootstrap-css
これを参考に以下のように用意します。

span.twitter-typeahead .tt-menu,
span.twitter-typeahead .tt-dropdown-menu {
  cursor: pointer;
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 1000;
  display: none;
  float: left;
  min-width: 100%;

  padding: 5px 0;
  margin: 2px 0 0;
  list-style: none;
  font-size: 14px;
  text-align: left;
  background-color: #ffffff;
  border: 1px solid #cccccc;
  border: 1px solid rgba(0, 0, 0, 0.15);
  border-radius: 4px;
  -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
  box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
  background-clip: padding-box;
}
span.twitter-typeahead .tt-suggestion {
  display: block;
  padding: 3px 20px;
  clear: both;
  font-weight: normal;
  line-height: 1.42857143;
  color: #333333;
  white-space: nowrap;
}
span.twitter-typeahead .tt-suggestion.tt-cursor,
span.twitter-typeahead .tt-suggestion:hover,
span.twitter-typeahead .tt-suggestion:focus {
  color: #ffffff;
  text-decoration: none;
  outline: 0;
  background-color: #337ab7;
}
.input-group.input-group-lg span.twitter-typeahead .form-control {
  height: 46px;
  padding: 10px 16px;
  font-size: 18px;
  line-height: 1.3333333;
  border-radius: 6px;
}
.input-group.input-group-sm span.twitter-typeahead .form-control {
  height: 30px;
  padding: 5px 10px;
  font-size: 12px;
  line-height: 1.5;
  border-radius: 3px;
}
span.twitter-typeahead {
  width: 100%;
}
.input-group span.twitter-typeahead {
  display: block !important;
  height: 34px;
}
.input-group span.twitter-typeahead .tt-menu,
.input-group span.twitter-typeahead .tt-dropdown-menu {
  top: 32px !important;
}
.input-group span.twitter-typeahead:not(:first-child):not(:last-child) .form-control {
  border-radius: 0;
}
.input-group span.twitter-typeahead:first-child .form-control {
  border-top-left-radius: 4px;
  border-bottom-left-radius: 4px;
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
}
.input-group span.twitter-typeahead:last-child .form-control {
  border-top-left-radius: 0;
  border-bottom-left-radius: 0;
  border-top-right-radius: 4px;
  border-bottom-right-radius: 4px;
}
.input-group.input-group-sm span.twitter-typeahead {
  height: 30px;
}
.input-group.input-group-sm span.twitter-typeahead .tt-menu,
.input-group.input-group-sm span.twitter-typeahead .tt-dropdown-menu {
  top: 30px !important;
}
.input-group.input-group-lg span.twitter-typeahead {
  height: 46px;
}
.input-group.input-group-lg span.twitter-typeahead .tt-menu,
.input-group.input-group-lg span.twitter-typeahead .tt-dropdown-menu {
  top: 46px !important;
}

Wicket の TextField を継承
キー入力イベント後のAJAX通信で返す JSON は、Goole gson で生成しています。

import java.util.List;
import java.util.Optional;
import org.apache.wicket.ajax.AbstractDefaultAjaxBehavior;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.attributes.AjaxRequestAttributes;
import org.apache.wicket.markup.html.form.TextField;
import org.apache.wicket.model.IModel;
import org.apache.wicket.request.IRequestParameters;
import org.apache.wicket.request.handler.TextRequestHandler;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
/**
 * Twitter Boostrap Typeahead TextField.
 */
public abstract class AjaxTypeahead<T extends Typeahead> extends TextField<String>{
   private AbstractDefaultAjaxBehavior queryAjaxBehavior;
   private List<Typeahead> choicelist;

   public AjaxTypeahead(String id, IModel<String> model){
      super(id, model);
      Gson gson = new GsonBuilder().serializeNulls().create();
      queryAjaxBehavior = new AbstractDefaultAjaxBehavior(){
         @Override
         protected void respond(AjaxRequestTarget target){
            IRequestParameters p = getRequestCycle().getRequest().getQueryParameters();
            Optional.ofNullable(p.getParameterValue("query").toString()).ifPresent(s->{
               choicelist = getChoices(s.trim());
               getComponent().getRequestCycle()
.replaceAllRequestHandlers(new TextRequestHandler("application/json", "UTF-8", gson.toJson(choicelist)));
            });
            Optional.ofNullable(p.getParameterValue("selected").toString()).ifPresent(s->{
               String id = p.getParameterValue("id").toString();
               for(Typeahead t:choicelist){
                  if (t.id.equals(id)){
                     onSelect(target, t);
                     break;
                  }
               }
            });
            Optional.ofNullable(p.getParameterValue("change").toString()).ifPresent(s->{
               String display = Optional.ofNullable(p.getParameterValue("display").toString())
                           .orElse("").trim();
               onChange(target, display);
            });
            getRequestCycle().getResponse().close();
         }
         @Override
         protected void updateAjaxAttributes(AjaxRequestAttributes attributes) {
            super.updateAjaxAttributes(attributes);
            attributes.setDataType("json");
            attributes.setWicketAjaxResponse(false);
         }
      };
      add(queryAjaxBehavior);
   }

   @Override
   protected void onAfterRender(){
      super.onAfterRender();
      getResponse().write("<script type=\"text/javascript\">");
      getResponse().write(" $(\"#" + this.getMarkupId(true) + "\").typeahead({highlight:true,minLength:1},{");
      getResponse().write("displayKey: 'display',");
      getResponse().write("limit: 7,");
      getResponse().write("source : new Bloodhound({");
      getResponse().write("datumTokenizer: function(datum){");
      getResponse().write("return Bloodhound.tokenizers.whitespace(datum.display);");
      getResponse().write("},");
      getResponse().write("queryTokenizer: Bloodhound.tokenizers.whitespace,");
      getResponse().write("remote:{");
      getResponse().write("wildcard: '%QUERY',");
      getResponse().write("url: '" + queryAjaxBehavior.getCallbackUrl() + "&query=%QUERY',");
      getResponse().write("transform: function(r){");
      getResponse().write("return $.map(r, function(t){");
      getResponse().write("return { display:t.value, id:t.id };");
      getResponse().write("});}}})");
      getResponse().write("}).on('typeahead:select', function(ev, item){");
      getResponse().write("var url = '" + queryAjaxBehavior.getCallbackUrl() + "' + '&selected=&id=' + item.id + '&display=' + item.display;");
      getResponse().write("Wicket.Ajax.get({ u: url });");
      getResponse().write("}).on('change', function(ev){");
      getResponse().write("var url = '" + queryAjaxBehavior.getCallbackUrl() + "' + '&change=&display=' + $(this).val();");
      getResponse().write("Wicket.Ajax.get({ u: url });");
      getResponse().write("});");
      getResponse().write("</script>");
   }

   /** 入力文字→候補リスト  */
   protected abstract List<Typeahead> getChoices(String input);

   /**
    * 選択イベント捕捉.
    * @param target AjaxRequestTarget
    * @param id
    * @param dislay
    */
   protected void onSelect(AjaxRequestTarget target, Typeahead typeahead){
   }
   /** 変更イベント捕捉    */
   protected void onChange(AjaxRequestTarget target, String dislay){
   }
}

表示用の素材、Typeahead は、id と プルダウン表示及び入力文字列をSerializableである。
この Typeahead を継承するかそのまま使用する。

import java.io.Serializable;

/**
 * twitter/Typeahead.js element.
 */
public class Typeahead implements Serializable{
   public String id;
   public String value;

   public Typeahead(){}

   public void setValue(String value){
      this.value = value;
   }
   public void setId(String id){
      this.id = id;
   }
}

使用する例: Example

AjaxTypeahead<Typeahead> simpleField = new AjaxTypeahead<>("name", new Model<>()){
   @Override
   protected List<Typeahead> getChoices(String input){
      // 入力が空 Enter では、空リストを送る。
      if (Strings.isEmpty(input)) return Collections.emptyList();
      // TODO 入力 input にマッチするリストを返す。
      return list;
   }
   @Override
   protected void onSelect(AjaxRequestTarget target, String id, String dislay){
      // TODO 選択時の処理、選択した要素の id と 表示文字列 display を受信
   }
   @Override
   protected void onChange(AjaxRequestTarget target,  String dislay){
      // TODO change イベントの処理、変更後の表示文字列 display を受信
   }
};
queue(simpleField);

あくまでも、TextField の継承であり、
フォーム送信で受け取る Wicket の Model オブジェクトは、String である。