Introduction
From the time I started playing with HTML, I was annoyed by the lack of a horizontal scrollbar on the <select>
element. So I played around with putting the <select>
into a <div>
, and after fiddling with the JavaScript for a while, I had a solution.
The first problem that came to light was that if you used your arrow keys to cursor down the list, you could disappear off the bottom of the <div>
. Nothing made the selected item stay within the visible area of the <div>
. I fixed that in the JavaScript as well.
And then came the move to ASP.NET. I wanted to encapsulate my solution in a server control, but I figured that as long as I was going to do that, I might as well correct three of the more annoying aspects of Microsoft's ListBox
server control:
ListBox
and DropDownList
, despite being the same control in almost every way, are separate server controls.
ListBox
(as well as DropDownList
) doesn't support the <optgroup>
tag, which is part of the <select>
element's object model.
ListBox
(and DropDownList
) share a bug that Microsoft calls "by design". You can add Attributes
to ListItem
s, but:
- they don't render, and
- they don't get saved in ViewState.
Which begs the question of why they bother with an Attributes
property in the ListItem
class in the first place. To me, it looks like they started to implement it, but never got around to finishing it.
Combining ListBox and DropDownList
The Microsoft ListControl
is used as a basis for ListBox
, DropDownList
, CheckBoxList
and RadioButtonList
. I have to assume that since the last two don't use <select>
as the HTML element that gets rendered on the Page
, they just decided to do all of them separately. Because the difference between ListBox
and DropDownList
are minor.
As I've done before, I used Lutz Roeder's Reflector to grab the code that makes up the ListBox
control. This is necessary, because ListItem
is not inheritable, and you'll see later on how we need to make some modifications in that class.
ListBox
inherits from ListControl
, and so does DropDownList
. I checked to see what the differences were between the two, and simply made those differences dependent upon an Enum
I created.
Public Enum XListType
ListBox = 0
DropDownList = 1
End Enum
I created a property in XList
based on this Enum
:
<DefaultValue(0), Category("Behavior"), Description("XList_XListType")> _
Public Overridable Property XListType() As XListType
Get
Dim obj1 As Object = Me.ViewState("XListType")
If (Not obj1 Is Nothing) Then
Return CType(obj1, XListType)
End If
Return XListType.ListBox
End Get
Set(ByVal value As XListType)
If ((value < XListType.ListBox) OrElse (value > _
XListType.DropDownList)) Then
Throw New ArgumentOutOfRangeException("value")
End If
Me.ViewState("XListType") = value
End Set
End Property
You can look at OpenList
, which I've included in the source code, to see all the places I made the code conditional on this property. For example, in the AddAttributesToRender
override, there was a bit of code having to do with the SelectionMode
property, which is only relevant to the ListBox
. You can't have multiple selections in a DropDownList
.
If Me.XListType = XListType.ListBox Then
writer.AddAttribute(HtmlTextWriterAttribute.Size, _
Me.Rows.ToString(NumberFormatInfo.InvariantInfo))
If (Me.SelectionMode = ListSelectionMode.Multiple) Then
writer.AddAttribute(HtmlTextWriterAttribute.Multiple, "multiple")
End If
End If
As you can see, I simply wrapped this piece of code in a conditional statement, so that it only runs if the XList
control is in ListBox
mode.
There were a few other stumbling blocks. Some of the original code references methods in other classes which are marked Friend
. This is fine for Microsoft's controls, since they're always going to be in the same assembly. But my control is in a separate assembly, so I got an error, telling me that the method was inaccessible.
I really didn't want to have to recreate the Page
control, which was the first control that caused me this problem. So I used Reflection to handle it.
The OnPreRender
override contained this line of code:
Me.Page.RegisterPostBackScript()
I replaced it with this:
Dim methodInfo As methodInfo = _
Me.Page.GetType.GetMethod("RegisterPostBackScript", _
BindingFlags.Instance Or BindingFlags.NonPublic)
If Not (methodInfo Is Nothing) Then
methodInfo.Invoke(Me.Page, New Object() {})
End If
When you're working with custom controls, you may find yourself running into this problem frequently. This is the way around it.
Also, the Microsoft code used the TryCast
method over and over. I understand that this will be available in the next version of .NET, but that didn't help me. So I had to convert those instances into code I could actually use.
For example, in the OnDataBinding
override, this line appeared:
Dim collection1 As ICollection = _
TryCast(enumerable1, ICollection)
I replaced it with this:
Dim collection1 As ICollection
If TypeOf enumerable1 Is ICollection Then
collection1 = CType(enumerable1, ICollection)
Else
collection1 = Nothing
End If
Because that's essentially what TryCast
does. If it can, it converts to the Type
you want, and if it can't it returns Nothing
.
That's about all I did to combine the two controls. As I said, you can find it as OpenList
in the source code.
Adding <optgroup> Support
It isn't actually that big a deal that ListBox
and DropDownList
are separate controls. But when you're as lazy as I am, the prospect of having to modify both controls when it's the same modification just seemed pointless. And since both ListBox
and DropDownList
really should support the <optgroup>
tag, combining them saved me time and work.
First, though, I had to decide how I was going to do this. In the XTable
control, where I added support for <thead>
, <tbody>
and <tfoot>
tags, I decided to add a hierarchy between Table
and TableRow
, which I called TableRowGroup
. I considered doing the same thing here; making XListItem
s be the children of an OptGroup
class, and having an OptGroupCollection
class in the XList
.
Luckily, I regained my sanity before I did that, and decided merely to add an OptGroup
property to the XListItem
s. This would permit the databinding functionality of the control to continue as before, while having XListItem
s in separate OptGroup
s would have made that a real mess. And I could deal with the <optgroup>
tags in XList
's RenderContents
method. So I created this property in XListItem
:
<DefaultValue("")> _
Public Property OptGroup() As String
Get
Return Me._optGroup
End Get
Set(ByVal value As String)
Me._optGroup = value
If Me.IsTrackingViewState Then
Me._misc.Set(4, True)
End If
End Set
End Property
You may wonder what this line is:
Me._misc.Set(4, True)
It seems that Microsoft's coders decided to put a global BitArray
variable in their ListItem
class. They put Const
declarations in the class which identifies what the various elements in this BitArray
indicate:
Private Const _SELECTED As Integer = 0
Private Const _MARKED As Integer = 1
Private Const _TEXTISDIRTY As Integer = 2
Private Const _VALUEISDIRTY As Integer = 3
For some reason, they chose not to use these constants, instead just using their numeric values, but it was helpful in letting me know what the items in _misc
were about. I added the following:
Private Const _OPTGROUPISDIRTY As Integer = 4
So when the OptGroup
property is modified through the Set
accessor, it marks the XListItem
as having been modified. The Text
and Value
properties already had this for _misc(2)
and _misc(3)
.
I also added some custom state management code for OptGroup
. In LoadViewState
and SaveViewState
, I replaced the state
object with an Object()
, and let state(0)
replace the state
variable that was used for the state of _misc
. I used state(1)
for the OptGroup
property.
In XList
's RenderContents
override, I added this code immediately before the rendering of each <option>
tag:
If Me.EnableOptGroups Then
Dim sPrevOptGroup As String
Dim sOptGroup As String = item1.OptGroup
If Not sOptGroup = sPrevOptGroup And Not num2 = 0 Then
writer.WriteEndTag("optgroup")
writer.WriteLine()
End If
If Not sOptGroup = sPrevOptGroup Or num2 = 0 Then
writer.WriteBeginTag("optgroup")
writer.WriteAttribute("label", sOptGroup)
writer.Write(">"c)
writer.WriteLine()
sPrevOptGroup = sOptGroup
End If
End If
When this was done, the XList
control supported <optgroup>
tags without a problem. But I decided that it might be nice to expand the databinding functionality to use OptGroup
as well as Text
and Value
for populating the control. So I added the following two properties:
<Description("XList_DataOptGroupField"), _
Category("Data"), DefaultValue("")> _
Public Overridable Property DataOptGroupField() As String
Get
Dim obj1 As Object = Me.ViewState("DataOptGroupField")
If (Not obj1 Is Nothing) Then
Return CType(obj1, String)
End If
Return String.Empty
End Get
Set(ByVal value As String)
Me.ViewState("DataOptGroupField") = value
End Set
End Property
<Description("XList_DataOptGroupFormatString"), _
DefaultValue(""), Category("Data")> _
Public Overridable Property DataOptGroupFormatString() As String
Get
Dim obj1 As Object = Me.ViewState("DataOptGroupFormatString")
If (Not obj1 Is Nothing) Then
Return CType(obj1, String)
End If
Return String.Empty
End Get
Set(ByVal value As String)
Me.ViewState("DataOptGroupFormatString") = value
End Set
End Property
I modified the various methods involved with the databinding to include these properties. You can take a look at the source code to see how that was done.
Oh, Where, Oh, Where, Have My Attributes Gone...?
I have no idea why Microsoft decided not to support ListItem
Attributes
. I use styles like background-color
and color
in my <select>
controls. I even use the label
attribute sometimes, to hold extra data from the database. It was extremely annoying to find that Microsoft knows about this lack of functionality, and won't even admit that it's a bug.
So I decided to workaround them. You'd think that the Attributes
property would already preserve state, since it looks like this:
<Browsable(False), _
DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)> _
Public ReadOnly Property Attributes() As System.Web.UI.AttributeCollection
Get
If (Me._attributes Is Nothing) Then
Me._attributes = New _
System.Web.UI.AttributeCollection(New StateBag(True))
End If
Return Me._attributes
End Get
End Property
After all, what's the point of the New StateBag
if it's not going to actually maintain state? But it clearly doesn't, and I had to find another way to go about this. I couldn't just save the Attribute
property in SaveViewState
and load it in LoadViewState
, because AttributeCollection
isn't serializable (which may be why it doesn't maintain state to begin with, now that I think about it). So I decided to use an array of Pair
s. I probably could have used a two dimensional array of String
s, but I didn't figure it would make much of a difference either way, and I rarely get to play with Pair
s.
First, I added a new global constant and changed _misc
to BitArray(6)
:
Private Const _ATTRIBUTESISDIRTY As Integer = 5
Then I added this line to the IAttributeAccessor.SetAttribute
method, in order to let attribute changes make the XListItem
dirty just like the other properties:
Me._misc.Set(5, True)
After that, it was simply a matter of modifying SaveViewState
and LoadViewState
, as follows:
Friend Sub LoadViewState(ByVal state As Object)
Dim arrState As Object() = CType(state, Object())
If (Not arrState(0) Is Nothing) Then
If TypeOf arrState(0) Is Pair Then
Dim pair1 As Pair = CType(arrState(0), Pair)
If (Not pair1.First Is Nothing) Then
Me.Text = CType(pair1.First, String)
End If
Me.Value = CType(pair1.Second, String)
Else
Me.Text = CType(arrState(0), String)
End If
End If
If Not arrState(1) Is Nothing Then
Me.OptGroup = CType(arrState(1), String)
End If
If Not arrState(2) Is Nothing Then
If TypeOf arrState(2) Is Pair() Then
Dim colAttributes As Pair() = CType(arrState(2), Pair())
For i As Integer = 0 To colAttributes.Length - 1
Me.Attributes.Add(colAttributes(i).First.ToString, _
colAttributes(i).Second.ToString)
Next i
End If
End If
End Sub
I loaded the Pair
array into the Attributes
property of the XListItem
.
Friend Function SaveViewState() As Object
Dim arrState(2) As Object
If (Me._misc.Get(2) AndAlso Me._misc.Get(3)) Then
arrState(0) = New Pair(Me.Text, Me.Value)
ElseIf Me._misc.Get(2) Then
arrState(0) = Me.Text
ElseIf Me._misc.Get(3) Then
arrState(0) = New Pair(Nothing, Me.Value)
Else
arrState(0) = Nothing
End If
arrState(1) = Me.OptGroup
If Me.Attributes.Count > 0 Then
ReDim _attributes2(Me.Attributes.Count - 1)
Dim i As Integer = 0
Dim keys As IEnumerator = Me.Attributes.Keys.GetEnumerator
Dim key As String
While keys.MoveNext()
key = CType(keys.Current, String)
_attributes2(i) = New Pair(key, Me.Attributes.Item(key))
i += 1
End While
arrState(2) = _attributes2
End If
Return arrState
End Function
I copied the Attributes
from the XListItem
into an array of Pair
s, and saved that into XListItem
's ViewState.
The changes necessary for ListItem
Attributes
to maintain their state are trivial and minor, and I can't figure out why the Microsoft controls don't do it.
Horizontal Scrolling
The previous changes are all cross-browser compatible. This one isn't.
The XList
control can be used in any browser, but turning on EnableHScroll
will keep it from working in most non-IE6+ browsers.
<Category("Appearance"), DefaultValue(False), Description("XList_EnableHScroll")> _
Public Overridable Property EnableHScroll() As Boolean
Get
Dim obj1 As Object = Me.ViewState("EnableHScroll")
If (Not obj1 Is Nothing) Then
Return CType(obj1, Boolean)
End If
Return False
End Get
Set(ByVal value As Boolean)
Me.ViewState("EnableHScroll") = value
End Set
End Property
The Height
and Width
properties are not always set in a ListBox
. Generally, Height
is ignored in favor of Size
, which sets the height of the ListBox
with a unit equal to the height of a ListItem
. When Width
is used, it is often set fairly wide, so as not to cut off any ListItem
s that are too long.
With our scrolling XList
, Height
and Width
are required. So we add default values in the constructor, like so:
If Me.EnableHScroll Then
If Me.Width.IsEmpty Then Me.Width = Unit.Pixel(100)
If Me.Height.IsEmpty Then Me.Height = Unit.Pixel(100)
End If
And we override the Height
and Width
properties to ensure that they are never empty:
<Browsable(True), _
DesignerSerializationVisibility(DesignerSerializationVisibility.Content)> _
Public Overrides Property Width() As Unit
Get
If Me.EnableHScroll AndAlso MyBase.Width.IsEmpty Then
Return Unit.Pixel(100)
End If
Return MyBase.Width
End Get
Set(ByVal Value As Unit)
MyBase.Width = Value
End Set
End Property
Putting the <div>
around the control means overriding RenderBeginTag
, just as we did in the XTable
. Again, we make it conditional, since we only need it when EnableHScroll
is True
. And we modify RenderEndTag
in a similar way:
Public Overrides Sub RenderEndTag(ByVal writer _
As System.Web.UI.HtmlTextWriter)
MyBase.RenderEndTag(writer)
If Me.EnableHScroll Then
writer.RenderEndTag()
End If
End Sub
In addition to the standard code I used to modify RenderBeginTag
, I added two events to the <select>
:
MyBase.Attributes.Add("onchange", "javascript:XList_ShowOption(this);")
MyBase.Attributes.Add("onresize", _
"javascript:XList_ResizeSelect(this);XList_ShowOption(this);")
These events trigger one or both of the functions that make the control work. XList_ResizeSelect
makes the <select>
the appropriate size. If the width of the <select>
is smaller than the width of the <div>
, this function widens the <select>
until it fills the width of the viewable space in the <div>
. Likewise for the height of the <div>
. If the number of <option>
elements in the <select>
leave a space between the bottom of the <select>
and the bottom of the <div>
, the function increases the height of the <select>
to fit. Otherwise, the size of the <select>
is set to the full number of <option>
elements in the <select>
.
function XList_ResizeSelect(objSelect){
if (objSelect.offsetHeight == 0){
return;
}
objSelect.onresize = null;
objSelect.size = objSelect.options.length < 2 ? 2 : _
objSelect.options.length;
if (objSelect.offsetHeight < _
objSelect.parentElement.offsetHeight - scrollbarWidth){
objSelect.style.height = _
objSelect.parentElement.offsetHeight - scrollbarWidth / 2;
}
objSelect.style.width = "";
if (objSelect.offsetWidth < objSelect.parentElement.offsetWidth_
- scrollbarWidth){
objSelect.style.width = (objSelect.parentElement.offsetWidth_
- scrollbarWidth) + "px";
} else {
objSelect.style.width = "auto";
}
}
A utility function calculates the width of a scrollbar when the page loads. I gleaned that useful technique from Scott Isaac's article on DHTML scrollbars.
I used the resize
event to trigger this, because it makes the most sense. If you add or remove an <option>
from the <select>
on the client side, you should call XList_ResizeSelect
for the <select>
in your own client code to ensure that the <select>
gets resized according to its new contents.
XList_ShowOption
is a little more complicated. As I mentioned at the beginning of this article (remember way back then? <grin>), if you use this technique and cursor down past the bottom of the <div>
, your selected <option>
will disappear from sight. So I needed to find a way to keep the <div>
scrolling, so that the selected <option>
would stay in view. It would have been nice to be able to use the scrollIntoView
method, but that method doesn't apply to the <option>
element.
Here's the code that makes the <option>
move into view. I kept the calculations all separate to make it easier to follow (and easier to debug, when I was originally working on this):
function XList_ShowOption(objSelect){
idx = objSelect.selectedIndex
if (idx == -1){
return;
}
if (objSelect.length == 0){
return;
}
objDiv = objSelect.parentElement;
HeightOfSelect = objSelect.clientHeight;
OptionsInSelect = objSelect.options.length;
HeightOfOption = HeightOfSelect / OptionsInSelect;
HeightOfDiv = objDiv.clientHeight;
OptionsInDiv = HeightOfDiv / HeightOfOption;
OptionTopFromTopOfSelect = HeightOfOption * idx;
OptionTopFromTopOfDiv = OptionTopFromTopOfSelect - objDiv.scrollTop;
OptionBottomFromBottomOfDiv = HeightOfDiv - _
OptionTopFromTopOfDiv - HeightOfOption;
if (OptionTopFromTopOfDiv < 0) {
objDiv.scrollTop = OptionTopFromTopOfSelect;
} else if (OptionBottomFromBottomOfDiv < 0 && _
OptionBottomFromBottomOfDiv > 0 - HeightOfOption) {
objDiv.scrollTop = objDiv.scrollTop + HeightOfOption;
} else if (OptionBottomFromBottomOfDiv < 0) {
objDiv.scrollTop = OptionTopFromTopOfSelect;
}
}
These two functions, along with the utility that figures out the width of a scrollbar, get written to the page once, regardless of how many instances of XList
there are on the page. I use RegisterClientScriptBlock
for that. But there's another thing we need to do with script, and it has to be done for each instance separately, which is what RegisterStartupScript
is for.
When the page loads, the <select>
needs to be resized. This has to happen for each instance of the control. In addition, if the control has a SelectedIndex
greater than -1, the page has to mark the correct <option>
as selected, and run XList_ShowOption
, to make sure that it's visible. You can see the scripts being registered in the source code.
In the design environment, however, scripts don't run. So I modified the XListDesigner
slightly. The GetDesignTimeHtml
method calls MyBase.GetDesignTimeHtml
at two points. I replaced those calls with a call to a new method I created called GetDesignTimeResize
. Here is the modified GetDesignTimeHtml
, and the new GetDesignTimeResize
:
Public Overrides Function GetDesignTimeHtml() As String
Dim collection1 As XListItemCollection = Me._xList.Items
If (collection1.Count > 0) Then
Return GetDesignTimeResize()
End If
If Me.IsDataBound Then
collection1.Add("bound")
Else
collection1.Add("unbound")
End If
Dim text1 As String = GetDesignTimeResize()
collection1.Clear()
Return text1
End Function
Public Overridable Function GetDesignTimeResize() As String
Dim _xList As XList = CType(Component, XList)
If _xList.EnableHScroll Then
Dim str As String = MyBase.GetDesignTimeHtml
str = Replace(str, "<select", _
"<select style=width:" & _xList.Width.ToString)
Dim _itemCount As Integer = _xList.Items.Count
Dim _selectTagEnd As Integer = InStr(str, ">")
_selectTagEnd = InStr(_selectTagEnd + 1, str, ">")
Dim _sizeAttribute As Integer = InStr(str, " size=")
If _sizeAttribute > 0 And _sizeAttribute < _selectTagEnd Then
str = Replace(str, "size=""4""", "size=""" & _
IIf(_itemCount < 2, 2, _itemCount).ToString & """")
Else
str = Replace(str, "<select", "<select size=""" _
& IIf(_itemCount < 2, 2, _itemCount).ToString & """")
End If
Return str
Else
Return MyBase.GetDesignTimeHtml
End If
End Function
About the only thing I found left to do was to add a few lines at the end of PreFilterProperties
to make sure that the DataOptGroupField
is treated the same way as the DataTextField
and the DataValueField
.
Conclusion
That's pretty much it. The control works in three modes: DropDownList
, ListBox
, Scrolling ListBox
, and in each of these cases, you can have OptGroup
s rendered or not. So it's sort of like six controls in one.