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.