Client Callbacks In Action Part 1: Auto Completing Text Boxes

I have talked about client callbacks in the past, and even provided a general-purpose control for invoking code on the server-side. This time, I will provide two more examples:

  • A text box control that displays a dynamic pick list generated from a server-side event handler;
  • A drop down list that automatically fills also based on a server-side event and a trigger value.

First, the auto complete text box. Basically, I want something similar to the AJAX Control Toolkit’s AutoCompleteExtender, but without any references to external web services, I want to use a regular server-side event instead, this is more consistent with the way ASP.NET controls work.

Here is my intended markup:

   1: <my:AutoCompleteTextBox runat="server" ID="text" MinCharacters="3" OnAutoComplete="text_AutoComplete" OnClientItemSelected="fillList" />

As you can see, we can set two properties:

  • MinCharacters: for setting the minimum number of characters after which the server-side AutoComplete event will be raised;
  • OnClientItemSelected: an optional JavaScript method that will be called whenever an item is selected on the suggestion list.

There’s also a server-side event, AutoComplete. This will be called whenever the number of entered characters on the text box reaches the value in MinCharacters.

Here’s a screenshot:

image

For customizing the suggestion list appearance, we have two properties:

  • PanelCssClass: for setting the CSS class of the boundary DIV;
  • PanelItemCssClass: the CSS class of each item in the suggestion list.

The AutoCompleteTextBox control itself inherits from TextBox and adds a couple of overrides. Here is its code:

   1: public class AutoCompleteTextBox : TextBox, ICallbackEventHandler
   2: {
   3:     private readonly Panel panel = new Panel();
   4:  
   5:     public AutoCompleteTextBox()
   6:     {
   7:         this.MinCharacters = 3;
   8:     }
   9:  
  10:     [DefaultValue(3)]
  11:     public Int32 MinCharacters { get; set; }
  12:  
  13:     public event EventHandler<AutoCompleteEventArgs> AutoComplete;
  14:  
  15:     [DefaultValue("")]
  16:     public String OnClientItemSelected
  17:     {
  18:         get;
  19:         set;
  20:     }
  21:  
  22:     public CssStyleCollection PanelStyle
  23:     {
  24:         get
  25:         {
  26:             return (this.panel.Style);
  27:         }
  28:     }
  29:  
  30:     [DefaultValue("")]
  31:     [CssClassProperty]
  32:     public String PanelItemCssClass
  33:     {
  34:         get;
  35:         set;
  36:     }
  37:  
  38:     [DefaultValue("")]
  39:     [CssClassProperty]
  40:     public String PanelCssClass
  41:     {
  42:         get
  43:         {
  44:             return (this.panel.CssClass);
  45:         }
  46:         set
  47:         {
  48:             this.panel.CssClass = value;
  49:         }
  50:     }
  51:  
  52:     protected override void OnInit(EventArgs e)
  53:     {
  54:         this.Page.LoadComplete += OnLoadComplete;
  55:  
  56:         base.OnInit(e);
  57:     }
  58:  
  59:     public override void Dispose()
  60:     {
  61:         this.Page.LoadComplete -= OnLoadComplete;
  62:  
  63:         base.Dispose();
  64:     }
  65:  
  66:     protected void OnLoadComplete(object sender, EventArgs e)
  67:     {
  68:         var index = this.Parent.Controls.IndexOf(this);
  69:  
  70:         this.Parent.Controls.AddAt(index + 1, this.panel);
  71:     }
  72:  
  73:     protected override void CreateChildControls()
  74:     {
  75:         this.panel.ID = String.Concat(this.ID, "_Panel");
  76:         this.panel.Style[HtmlTextWriterStyle.Display] = "none";
  77:         this.panel.Style[HtmlTextWriterStyle.Position] = "absolute";
  78:  
  79:         var script = String.Empty;
  80:  
  81:         if (String.IsNullOrWhiteSpace(this.OnClientItemSelected) == false)
  82:         {
  83:             var index = this.OnClientItemSelected.IndexOf('(');
  84:  
  85:             if (index >= 0)
  86:             {
  87:                 script = this.OnClientItemSelected.Replace("'", "\\'");
  88:                 script = script.Replace("\"", "\\\"");
  89:             }
  90:             else
  91:             {
  92:                 script = String.Concat(this.OnClientItemSelected, "(document.getElementById(\\'", this.ClientID, "\\'), this.innerHTML)");
  93:             }
  94:         }
  95:  
  96:         this.Page.ClientScript.RegisterStartupScript(this.GetType(), this.UniqueID + "onSuggestionCallback", String.Format("Sys.WebForms.PageRequestManager.getInstance().add_pageLoaded(function() {{ document.getElementById('{0}').onSuggestionCallback = function(result, context) {{ var html = ''; var r = result.split('\\n'); for (var i = 0; i < r.length; ++i) {{ html += '<a href=\"#\" style=\"display:block\" class=\"{2}\" onclick=\"document.getElementById(\\'{0}\\').value = this.innerHTML; document.getElementById(\\'{1}\\').style.display = \\'none\\'; {3} \">' + r[i] + '</a>'; }}; document.getElementById('{1}').innerHTML = html; document.getElementById('{1}').style.display = ''; }} }});", this.ClientID, this.panel.ClientID, this.PanelItemCssClass ?? String.Empty, script), true);
  97:         this.Page.ClientScript.RegisterStartupScript(this.GetType(), this.UniqueID + "getSuggestions", String.Format("Sys.WebForms.PageRequestManager.getInstance().add_pageLoaded(function() {{ document.getElementById('{0}').getSuggestions = function() {{ {1} }} }});\n", this.ClientID, String.Format(this.Page.ClientScript.GetCallbackEventReference(this, "document.getElementById('{0}').value", "document.getElementById('{0}').onSuggestionCallback", null, true), this.ClientID)), true);
  98:         this.Page.ClientScript.RegisterStartupScript(this.GetType(), this.UniqueID + "addEventListener", String.Format("Sys.WebForms.PageRequestManager.getInstance().add_pageLoaded(function() {{ document.getElementById('{0}').addEventListener('keyup', function(e) {{ if (e.target.value.length >= {1}) {{ document.getElementById('{0}').getSuggestions(); }} }}) }});\n", this.ClientID, this.MinCharacters), true);
  99:  
 100:         base.CreateChildControls();
 101:     }
 102:  
 103:     protected virtual void OnAutoComplete(AutoCompleteEventArgs e)
 104:     {
 105:         var handler = this.AutoComplete;
 106:  
 107:         if (handler != null)
 108:         {
 109:             handler(this, e);
 110:         }
 111:     }
 112:  
 113:     #region ICallbackEventHandler Members
 114:  
 115:     String ICallbackEventHandler.GetCallbackResult()
 116:     {
 117:         var output = this.Context.Items["Results"] as IEnumerable<String>;
 118:  
 119:         return (String.Join(Environment.NewLine, output));
 120:     }
 121:  
 122:     void ICallbackEventHandler.RaiseCallbackEvent(String eventArgument)
 123:     {
 124:         var args = new AutoCompleteEventArgs(eventArgument);
 125:  
 126:         this.OnAutoComplete(args);
 127:  
 128:         this.Context.Items["Results"] = args.Results;
 129:     }
 130:  
 131:     #endregion
 132: }

As you can see, in order to use the client callbacks functionality we need to implement ICallbackEventHandler, pretty straightforward. In its RaiseCallbackEvent we raise the AutoComplete event and store its output in the request itself (HttpContext.Items collection). Then, on GetCallbackResult, we take this value and pass it to the client, this works because the request – and hence HttpContext.Items - is the same.

The AutoComplete event’s argument is pretty straightforward, basically it exposes two properties:

  • Parameter: the value entered in the text box (read-only);
  • Results: the suggestion strings to return.
   1: [Serializable]
   2: public sealed class AutoCompleteEventArgs : EventArgs
   3: {
   4:     public AutoCompleteEventArgs(String parameter)
   5:     {
   6:         this.Parameter = parameter;
   7:         this.Results = Enumerable.Empty<String>();
   8:     }
   9:  
  10:     public String Parameter { get; private set; }
  11:  
  12:     public IEnumerable<String> Results { get; set; }
  13: }

One possible event handler implementation could be:

   1: protected void text_AutoComplete(object sender, AutoCompleteEventArgs e)
   2: {            
   3:     //just return 10 strings starting with the given one
   4:     e.Results = Enumerable.Range(0, 10).Select(x => e.Parameter + x.ToString());
   5: }

The panel where the suggestion list is to be displayed is initially hidden by using CSS’ display attribute. This panel is added to the target page in its LoadComplete event, this has to be this way, because we can’t change the page’s controls in the Init or Load events, and PreRender is not called in asynchronous requests.

On CreateChildControls we register some JavaScript to be raised whenever the page loads – that is, the first time and whenever an asynchronous operation finishes. This has the effect that this control requires a ScriptManager to be present on the page, which usually is not a problem. The JavaScript code may be hard to read, but it is quite simple. What it does is:

  • Adds a method to the text box, getSuggestions, that fires up the client callback method on the server, using ASP.NET magic;
  • Registers a JavaScript event handler to the text box’s keyup event, which fires whenever a key is released, and when the length of the text is appropriate, calls the getSuggestions method;
  • Attaches a JavaScript callback method, onSuggestionCallback, to the text box which is called whenever the client callback completes (successfully or not) and populates the suggestion list from the result;
  • When a value is selected from the suggestion list, it hides and sets the text box’s value; also, if the OnClientItemSelected is set, its value will be called.

The OnClientItemSelected supports two types of code:

  • A name of a JavaScript function, such as “populateList”; in this case, it will be called with two parameters: the text box DOM element and the value chosen from the suggestion list;
  • A full featured JavaScript expression, such as “populateList(document.getElementById(‘text), document.getElementById(‘text’).value)”. This will be used “as-is”.

All of this is done with pure JavaScript and no external library other than Microsoft’s AJAX Library.

Yes, I know, one could add lots of other fancy stuff, I leave it to you! I didn’t want to add a dependency on anything else than the ASP.NET AJAX infrastructure, that’s why I kept things simple.

On the second part I will proceed with the drop down list. Stay tuned! Winking smile

                             

No Comments