Custom HTML Dropdown control (part 2) - The server control

In this part we are going to take the custom DropDown html control that we created in part 1 and create an ASP.NET server control so we can use it in our Web Forms.

The control in part 1 was a fully working drop down control that behaves very close to the regular <select> html control but, if you set a specific width to the control, it expands the list of options to show the full width of the options in IE6 & 7 (which does not happen with the regular select html control).

To create a server control for our drop down, we are going to inherit from the regular DropDownList control to get all its databinding functionality and we are going to override some of the rendering members to emit a different html markup. We also going to implement IPostBackDataHandler to manage the post back data. So our class declaration will be:

 

   1: public partial class DropDownSelect : DropDownList, IPostBackDataHandler
   2: {

 

The Markup

Then we override the RenderBeginTag to emit the same markup as we created it in part 1:

   1: public override void RenderBeginTag(HtmlTextWriter writer)
   2: {
   3:     // render the wrapper div for the full control
   4:     writer.WriteBeginTag("div");
   5:     writer.WriteAttribute("ID", ClientID);
   6:     writer.WriteAttribute("Name", this.UniqueID);
   7:     if (!string.IsNullOrEmpty(CssClass))
   8:     {
   9:         writer.WriteAttribute("class", "controlWrapper " + CssClass);
  10:     }
  11:     else
  12:     {
  13:         writer.WriteAttribute("class", "controlWrapper");
  14:     }
  15:     writer.Write(">");
  16:  
  17:     // textbox, arrow and hiiden value field
  18:     RenderTextBoxSpan(writer);
  19: }
  20:  
  21: private void RenderTextBoxSpan(HtmlTextWriter writer)
  22: {
  23:     writer.WriteBeginTag("div");
  24:     writer.Write(">");
  25:  
  26:     writer.WriteBeginTag("span");
  27:     if (!string.IsNullOrEmpty(CssClass))
  28:     {
  29:         writer.WriteAttribute("class", "textBoxWrapper " + CssClass);
  30:     }
  31:     else
  32:     {
  33:         writer.WriteAttribute("class", "textBoxWrapper");
  34:     }
  35:  
  36:     writer.Write(">");
  37:  
  38:     // text box
  39:     writer.WriteBeginTag("input");
  40:     writer.WriteAttribute("ID", this.ClientID + "_ddText");
  41:     writer.WriteAttribute("Name", this.UniqueID + "$ddText");
  42:     writer.WriteAttribute("type", "text");
  43:     writer.WriteAttribute("readonly", null);
  44:  
  45:     if (!string.IsNullOrEmpty(CssClass))
  46:     {
  47:         writer.WriteAttribute("class", "ddTextBox " + CssClass);
  48:     }
  49:     else
  50:     {
  51:         writer.WriteAttribute("class", "ddTextBox");
  52:     }
  53:     writer.WriteAttribute("style", "width:" + Width.ToString());
  54:  
  55:     // set the value in the textbox if we have any
  56:     ListItem item = this.Items.FindByValue(SelectedValue);
  57:     if (item != null)
  58:     {
  59:         writer.WriteAttribute("value", item.Text);
  60:     }
  61:  
  62:     writer.Write(" />");
  63:  
  64:     // open dropdown arrow
  65:     writer.WriteBeginTag("img");
  66:     writer.WriteAttribute("src", Page.ClientScript.GetWebResourceUrl(
  67:         typeof(DropDownSelect), "DropDownSelect.Images.downArrow.gif"));
  68:     writer.WriteAttribute("align", "texttop");
  69:     writer.WriteAttribute("ID", this.ClientID + "_btnOpenDropDown");
  70:     writer.WriteAttribute("class", "arrowImg");
  71:     writer.Write(" />");
  72:  
  73:     writer.WriteEndTag("span");
  74:  
  75:     // clearing div so the listbox does not show in the wrong place
  76:     writer.WriteBeginTag("div");
  77:     writer.WriteAttribute("style", "clear:both;");
  78:     writer.Write(">");
  79:     writer.WriteEndTag("div");
  80:  
  81:     // ending textbox wrapper div
  82:     writer.WriteEndTag("div");
  83:  
  84:     // hidden value field
  85:     writer.WriteBeginTag("input");
  86:     writer.WriteAttribute("Id", this.ClientID + "_ddValue");
  87:     writer.WriteAttribute("Name", this.UniqueID);
  88:     writer.WriteAttribute("type", "text");
  89:     writer.WriteAttribute("style", "display:none;");
  90:     if (item != null)
  91:         writer.WriteAttribute("value", item.Value);
  92:     writer.Write(" />");
  93:  
  94:     // listbox wrapper div
  95:     writer.WriteLine();
  96:     writer.WriteBeginTag("div");
  97:     writer.WriteAttribute("ID", this.ClientID + "_listBox");
  98:     writer.WriteAttribute("class", "listBox");
  99:     writer.WriteAttribute("tabindex", "0");
 100:     writer.Write(">");
 101:     writer.WriteLine();
 102:  
 103:     // opening ul... the rest is rendered in the RenderContents()
 104:     writer.WriteFullBeginTag("ul");
 105: }

 

 

Nothing really special here, we created the same markup as in part 1 up to where the data for the options has to be inserted... and that's the job of the RenderContents member so we override that one too, and create a <li> for each option:

   1: protected override void RenderContents(HtmlTextWriter writer)
   2: {
   3:     ListItemCollection items = this.Items;
   4:  
   5:     for (int j = 0; j < items.Count; j++)
   6:     {
   7:         ListItem item = items[j];
   8:  
   9:         writer.WriteBeginTag("li");
  10:         writer.WriteAttribute("itemVal", item.Value, true);
  11:         writer.WriteAttribute("itemIndex", j.ToString(), true);
  12:  
  13:         if (item.Selected)
  14:         {
  15:             writer.WriteAttribute("selected", "selected");
  16:         }
  17:  
  18:         if (item.Attributes != null && item.Attributes.Count > 0)
  19:         {
  20:             item.Attributes.Render(writer);
  21:         }
  22:  
  23:         if (this.Page != null)
  24:         {
  25:             this.Page.ClientScript.RegisterForEventValidation(
  26:                 this.UniqueID, item.Value);
  27:         }
  28:  
  29:         writer.Write(">");
  30:         HttpUtility.HtmlEncode(item.Text, writer);
  31:         writer.WriteEndTag("li");
  32:         writer.WriteLine();
  33:     }
  34: }

 

Note that we are using the Items collection from the DropDownList base control so all the functionality for databinding, and accessing the items collection still works the same, we are just emitting new html. Then we need to render the closing html tags for our control, and that is done in the RenderEndTag member:

 

   1: public override void RenderEndTag(HtmlTextWriter writer)
   2: {
   3:     // end of the ul of items
   4:     writer.WriteEndTag("ul");
   5:  
   6:     // end of the listbox div
   7:     writer.WriteEndTag("div");
   8:  
   9:     // end of the control div
  10:     writer.WriteEndTag("div");
  11: }


In part 1, we needed to re-hook all the javascript events on every postback, in this part I've upgraded the code to use jquery 1.3.2 and the new  .live event hook functionality, which means we don't have to re-hook all the javascript events, we just need to initialize the control once. We emit the script for that in the PreRender step:

 

   1: protected override void OnPreRender(EventArgs e)
   2: {
   3:     base.OnPreRender(e);
   4:  
   5:     // we need to register a script to initialize the dropdown and for a 
   6:     // strange quirk in IE with the css of the dropdown, we need to hide all
   7:     // list boxes on every post back
   8:     if (!Page.IsPostBack)
   9:     {
  10:         string script = @"
  11:         var dd = new listBoxInstance('" + this.ClientID + @"', " + 
  12:               this.AutoPostBack.ToString().ToLower() + @");
  13:        dd.doInit();
  14:         ";
  15:  
  16:         ScriptManager.RegisterStartupScript(this, this.GetType(), 
  17:             "registerDropDownHandlers" + this.ClientID, script, true);
  18:  
  19:         script = @"var prm = Sys.WebForms.PageRequestManager.getInstance(); 
  20:         prm.add_endRequest(EndRequest);
  21:         function EndRequest(sender, args)
  22:         {
  23:             $('div[id*=_listBox]').css('display','none');
  24:         }";
  25:  
  26:         ScriptManager.RegisterStartupScript(this, this.GetType(), 
  27:             "closeDropDowns", script, true);
  28:     }
  29: }

 

Now, you may notice that we are registering a closeDropDown script also, the problem is that in IE there's a strange behavior when you set the initial state of the list of options (the listbox) to display:none: when you display it, it does not stretches  correctly and does not show the overflow scrolls correctly, so we just hide it on every postback.

 

The Postback data

The only extra thing we need to do is handle the postback data in our control by implementing the IPostBackDataHandler interface

 

   1: public new bool LoadPostData(string postDataKey, NameValueCollection postCollection)
   2: {
   3:     // get the new selected value from the postback data collection
   4:     // and if it's different from the current one, select it onto our property
   5:     // and return true, so we get a RisePostDataChangedEvent call
   6:     string newSelectedValue = postCollection[postDataKey];
   7:     if (newSelectedValue != null)
   8:     {
   9:         if (newSelectedValue != SelectedValue)
  10:         {
  11:             SelectedValue = newSelectedValue;
  12:             return true;
  13:         }
  14:     }
  15:  
  16:     return false;
  17: }
  18:  
  19: public new void RaisePostDataChangedEvent()
  20: {
  21:     this.OnSelectedIndexChanged(EventArgs.Empty);
  22: }
  23:  

 

When our control generates a postback, we get called on the LoadPostData with the values of the postback, so if the selected value has changed, we change it and return true, so the framework calls the RaisePostDataChangedEvent

One last thing we need to do and that has to do with the way DropDownList control handles the SelectedValue and SelectedIndex properties. Before we bind the data to our control using the base class, we need to remember our SelectedValue and set it back after the binding. During the binding step, the DropDownList control uses an internal cache for selected items that we (at the derived class) don't have access, so it will try to set the SelectedValue from that cache and there's where we loose our selection, so we do:

 

   1: protected override void PerformDataBinding(IEnumerable dataSource)
   2: {
   3:     // for some reason (it has to do with the cachedSelectedIndex in the DropDownList)
   4:     // we need to preserve the selectedValue before the databind and then re-set it 
   5:     // after the call to the base PerformeDataBinding
   6:     string selectedValue = SelectedValue;
   7:     base.PerformDataBinding(dataSource);
   8:     SelectedValue = selectedValue;
   9: }

 

And that's it for the server control class. I've also updated the javascript to use jquery 1.3.2 and the live events:

 

   1: // Initializes the dropdown and hooks the events
   2: function init() {
   3:     var baseId = this.baseClientId;
   4:     var shouldPostBack = this.autoPostBack;
   5:     var $baseDiv = $('#' + baseId);
   6:     var $items = $('div[id*=_listBox] ul li', $baseDiv);
   7:     var $listBox = $('div[id*=_listBox]', $baseDiv);
   8:     var $ddText = $('input[id*=_ddText]', $('#' + baseId));
   9:     var $ddValue = $('input[id*=_ddValue]', $baseDiv);
  10:     var $btn = $('img[id*=_btnOpenDropDown]', $('#' + baseId));
  11:  
  12:     var minWidth = $ddText.outerWidth() + $btn.outerWidth();
  13:     var offset = $ddText.eq(0).offset().left - 1;
  14:     $listBox.css("display", "none");
  15:     FixIeHeight($items.length, $listBox)
  16:  
  17:     $btn.live("click", function() {
  18:         var $baseDiv = $('#' + baseId);
  19:         var $listBox = $('div[id*=_listBox]:hidden', $baseDiv);
  20:  
  21:         if ($listBox.length > 0) {
  22:             $listBox.css("left", offset).css("min-width", minWidth);
  23:             $listBox.show();
  24:             $listBox.scrollTop(0).focus();
  25:             BindHoverAndBlur(baseId);
  26:             return false;
  27:         }
  28:     });
  29:  
  30:     $items.live("click", function(e) {
  31:         HideItemList(baseId);
  32:         var $baseDiv = $('#' + baseId);
  33:         var $ddText = $('input[id*=_ddText]', $baseDiv);
  34:         var $ddValue = $('input[id*=_ddValue]', $baseDiv);
  35:  
  36:         $ddText.val($(this).text());
  37:         $ddValue.val($(this).attr("itemVal"));
  38:         $ddText.select();
  39:         if (shouldPostBack)
  40:             setTimeout('__doPostBack(\'' + baseId.replace(/_/g, '$') + '\',\'selectedindexchanged\')', 0);
  41:     });
  42:  
  43:     $ddText.live("click", function() {
  44:         $('img[id*=_btnOpenDropDown]', $('#' + baseId)).click();
  45:     });
  46:  
  47: }
  48:  
  49: function listBoxInstance(clientId, autoPostBack) {
  50:     this.baseClientId = clientId;
  51:     this.wasBlur = false;
  52:     this.autoPostBack = autoPostBack;
  53:     this.doInit = init;
  54: }
  55:  
  56: function BindHoverAndBlur(baseId) {
  57:     var $baseDiv = $('#' + baseId);
  58:     var $listBox = $('div[id*=_listBox]', $baseDiv);
  59:  
  60:     // very ugly way to detect IE6... we should use jQuery.support
  61:     // but haven't found a way to detect the anything:hover feature support
  62:     if( isIe6()) {
  63:         var $items = $('div[id*=_listBox] ul li', $baseDiv);
  64:         $items.hover(
  65:             function() { $(this).addClass("highLight"); },
  66:             function() { $(this).removeClass("highLight"); }
  67:         );
  68:         FixIeHeight($items.length, $listBox);
  69:     }
  70:     $listBox.blur(function(e) {
  71:         HideItemList(baseId);
  72:         this.wasBlur = true;
  73:         return false;
  74:     });
  75: }
  76:  
  77: function FixIeHeight(itemsCount, div) {
  78:     if (isIe6()) {
  79:         var h;
  80:         if (itemsCount > 10)
  81:             h = "10em";
  82:         else
  83:             h = "auto";
  84:         div.css("height", h);
  85:     }
  86: }
  87:  
  88: function HideItemList(baseId) {
  89:     var $listBox = $('div[id*=_listBox]:visible', $('#' + baseId));
  90:     if ($listBox.length > 0)
  91:         $listBox.hide(10);
  92: }
  93:  
  94: function isIe6() {
  95:     if ($.browser.msie && $.browser.version.substr(0) <= 6)
  96:         return true;
  97:     return false;
  98: }
  99:  

 

You'll notice that I embedded the css, image and javascript in the same package so you just need one dll for the whole thing to work.

You can download the full server control code here

 

Improvements

Some improvements could still be made to the control:

- I don't really like the fact that I had to include some browser specific code in the javascript to work around some IE6 bugs/missing functionality

- Still fills a bit sluggish when you have a lot of options in the drop down, so some optimizations may improve this.

 

3 Comments

  • how do u use it when u include the dll in a webapplication?

  • rajat: You would use it as any other user control, just register it on the page using the directive or add a registration in your web.config's controls section like:



    Don't forget to add a reference to the assembly in your project.

  • I am getting little problem with this. I am getting this error:

    Webpage error details

    User Agent: Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; .NET CLR 1.0.3705; .NET CLR 2.0.50727; InfoPath.2; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729; OfficeLiveConnector.1.3; OfficeLivePatch.0.0)
    Timestamp: Mon, 14 Jun 2010 23:29:59 UTC


    Message: Object expected
    Line: 7
    Char: 5
    Code: 0
    URI: http://localhost/MobInvestor2/WebResource.axd?d=WmTgRauM0aDXca4SnO-xpOmsopujZUtngeTZFS-aJ2PwYXJ8EIEAIjLDJrCyq13bREragMuVQvp5uCe6sCJbTg2&t=634121402267343750

    All the files are loading fine as I checked. The image is loading, menu is loading but expanded, and this error.

    Any idea?

Comments have been disabled for this content.