Introduction
Some time ago, I ran into a situation in which I wanted to have a table's headers repeat at the top of each page when printing. In order to do this, I needed to use CSS on the <thead>
element of the table, but since I was using ASP.NET, I couldn't, because the Microsoft Table
server control does not support <thead>
, <tbody>
and <tfoot>
tags.
When I found that parts of the Table
control were not inheritable (the RowCollection
and CellCollection
classes), I decided to do without. Recently, however, I found a way around that, so I decided to build the control.
I'd also run across a clever way to freeze top- and left-headers in a table, and I felt it would be nice to incorporate both of these "upgrades" to the standard Table
control.
Since I work in an IE-only environment, I wasn't concerned about cross-browser compatibility. As it happens, though, the extra tags which part of the modification have no CSS and no script, so it is fully cross-browser compatible.
Adding <thead>, <tbody> and <tfoot> tags
My first step was to use Lutz Roeder's Reflector to obtain an open copy of the code for the standard Microsoft Table
server control. I've included this in the source code as OpenTable
.
What the <thead>
, <tbody>
and <tfoot>
tags have in common is that they are all groupings of table rows (<tr>
). So I extended the Microsoft code to include a RowGroup
level between the Table
and TableRow
levels.
For example, the Table
class overrides CreateControlCollection
as follows:
Protected Overrides Function CreateControlCollection() As ControlCollection
Return New RowControlCollection(Me)
End Function
So the XTable
class does this:
Protected Overrides Function CreateControlCollection() As ControlCollection
Return New RowGroupControlCollection(Me)
End Function
The Rows
property of Table
looks like this:
<Description("Table_Rows"), _
PersistenceMode(PersistenceMode.InnerDefaultProperty), _
MergableProperty(False)> _
Public Overridable ReadOnly Property Rows() As TableRowCollection
Get
If Me._rows Is Nothing Then
Me._rows = New TableRowCollection(Me)
End If
Return Me._rows
End Get
End Property
So now the RowGroups
property of XTable
looks like this:
<Description("XTable_RowGroups"), _
PersistenceMode(PersistenceMode.InnerDefaultProperty), _
MergableProperty(False)> _
Public Overridable ReadOnly Property RowGroups() As XTableRowGroupCollection
Get
If Me._rowGroups Is Nothing Then
Me._rowGroups = New XTableRowGroupCollection(Me)
End If
Return Me._rowGroups
End Get
End Property
The change is fairly simple. Only two places require anything complicated. The first one was the GetDesignTimeHtml
override in the Designer
. Here too, it was straightforward, once the logic of the method was understood, but adding the extra level required some complexity. I'm not going to go through the whole method here, but if you compare the code in OpenTable
and XTable
, it should be fairly clear.
The other place was the constructor for the RowGroups
class. The constructor for the Rows
class looked like this:
Public Sub New()
MyBase.New(HtmlTextWriterTag.Tr)
End Sub
In the case of the XTable
, however, RowGroups
could be rendered as either <thead>
, <tbody>
or <tfoot>
tags. So I changed it to use HtmlTextWriterTag.Tbody
as a default, and allowed the render methods to determine which tag to actually render.
Towards this end, I added a few properties to the XTable
. EnableTHead
, if set to True
, causes the first RowGroup
to render as <thead>
:
Public Property EnableTHead() As Boolean
Get
Return CType(Me.ViewState("EnableTHead"), Boolean)
End Get
Set(ByVal Value As Boolean)
If Not Value Then
Me.FreezeTop = False
End If
Me.ViewState("EnableTHead") = Value
End Set
End Property
The EnableTFoot
property does the same for the last RowGroup
, although if there is only one RowGroup
and both EnableTHead
and EnableTFoot
are set to True
, the RowGroup
gets rendered as a <thead>
. And the EnableTBodies
property, which defaults to True
, determines whether any tag is rendered at all for the RowGroup
. If you set it to False
, no <tbody>
tags are rendered.
Here is the code for RenderStartTag
and RenderEndTag
:
Public Overrides Sub RenderBeginTag(ByVal writer As HtmlTextWriter)
Me.AddAttributesToRender(writer)
Dim _tag As HtmlTextWriterTag
Dim _table As XTable = CType(Parent, XTable)
If _table.EnableTHead And Me Is _table.RowGroups(0) Then
writer.RenderBeginTag(HtmlTextWriterTag.Thead)
ElseIf _table.EnableTFoot And Not _table.EnableTHead _
And Me Is _table.RowGroups(_table.RowGroups.Count - 1) Then
writer.RenderBeginTag(HtmlTextWriterTag.Tfoot)
ElseIf _table.EnableTFoot And Not _table.RowGroups.Count < 2 _
And Me Is _table.RowGroups(_table.RowGroups.Count - 1) Then
writer.RenderBeginTag(HtmlTextWriterTag.Tfoot)
ElseIf _table.EnableTBodies Then
writer.RenderBeginTag(HtmlTextWriterTag.Tbody)
End If
End Sub
Public Overrides Sub RenderEndTag(ByVal writer As HtmlTextWriter)
Dim _table As XTable = CType(Parent, XTable)
If _table.EnableTHead Or _table.EnableTBodies Or _table.EnableTFoot Then
writer.RenderEndTag()
End If
End Sub
In pseudo code, it would look like this:
If <thead>s are enabled AND this rowgroup is the first one:
Render as <thead>
Otherwise if <tfoot>s are enabled AND <thead>s
are not, AND this rowgroup is the last one:
Render as <tfoot>
Otherwise if <tfoot>s are enabled AND there
are at least 2 rowgroups, AND this is the last one:
Render as <tfoot>
Otherwise if <tbodies> are enabled:
Render as <tbody>
An end tag is only rendered for the RowGroup
if at least one of the RowGroup
tag types is enabled.
I could have stopped here, and I would have had a serviceable Table
server control that supports these three additional tags. It would also be fully cross-browser compatible, since, as I mentioned earlier, there is no CSS and no script involved. But I still wanted to be able to freeze headers.
Freezing headers
I'd come across Brett Merkey's terrific example of how to freeze headers in an HTML table using CSS expressions. His example uses style sheets, but I get nervous about using style sheets, since I can't control what other style sheets a user might use which might conflict with those of the control. I prefer to use inline CSS.
First, I created five new properties:
OuterHeight
and OuterWidth
are the dimensions of the <div>
which wraps around our table.
FreezeTop
is a Boolean
which determines whether the first RowGroup
is to be frozen or not.
FreezeLeft
is an Integer
which determines how many columns on the left are to be frozen.
FreezeStyle
inherits from TableItemStyle
, and determines the style of any cells in frozen areas.
The original Table
control overrides AddAttributesToRender
in the TableCell
to add Colspan
and Rowspan
attributes. I went further:
Protected Overrides Sub AddAttributesToRender(ByVal writer As HtmlTextWriter)
MyBase.AddAttributesToRender(writer)
Dim i As Integer = Me.ColumnSpan
If i > 0 Then
writer.AddAttribute(HtmlTextWriterAttribute.Colspan, _
i.ToString(NumberFormatInfo.InvariantInfo))
End If
i = Me.RowSpan
If i > 0 Then
writer.AddAttribute(HtmlTextWriterAttribute.Rowspan, _
i.ToString(NumberFormatInfo.InvariantInfo))
End If
My added code
Dim _row As XTableRow = CType(Parent, XTableRow)
Dim _rowgroup As XTableRowGroup = CType(Parent.Parent, XTableRowGroup)
Dim _table As XTable = CType(Parent.Parent.Parent, XTable)
Dim isFrozenTop As Boolean = (_table.FreezeTop AndAlso _
_table.EnableTHead AndAlso _rowgroup Is _table.RowGroups(0))
Dim isFrozenLeft As Boolean = (_table.FreezeLeft > 0 _
AndAlso _row.Controls.IndexOf(Me) < _table.FreezeLeft)
If isFrozenTop And isFrozenLeft Then
writer.AddStyleAttribute("z-index", "30")
writer.AddStyleAttribute("top", _
"expression(parentNode.parentNode.parentNode.parentNode.scrollTop-2)")
writer.AddStyleAttribute("left", _
"expression(parentNode.parentNode.parentNode.parentNode.scrollLeft-2)")
ElseIf isFrozenTop Then
writer.AddStyleAttribute("z-index", "20")
writer.AddStyleAttribute("top", _
"expression(parentNode.parentNode.parentNode.parentNode.scrollTop-2)")
ElseIf isFrozenLeft Then
writer.AddStyleAttribute("z-index", "10")
writer.AddStyleAttribute("left", _
"expression(parentNode.parentNode.parentNode.parentNode.scrollLeft-2)")
End If
If isFrozenTop Or isFrozenLeft Then
writer.AddStyleAttribute("position", "relative")
_table.FreezeStyle.AddAttributesToRender(writer)
End If
End Sub
Preserving scroll position on postback
It would have been extremely annoying to use this new control if every time the page was posted back, the table scrolled back to its original position. So I adapted the functionality used in Microsoft's now-unsupported TabStrip
control for my own use. Essentially, I created a hidden control on the page, and added an onscroll
event to the <div>
tag, which writes the scroll positions to the hidden control. A typical value might be 34:50, if the vertical scroll is displaced by 34 pixels and the horizontal scroll is displaced by 50 pixels.
Private Sub RegisterScript()
Dim divID As String = Me.ID + "_div"
Dim _scrollPos() As String = Me.HelperData.Split(":"c)
Dim script As String = "<script language='javascript' type='text/javascript' >" _
+ ControlChars.CrLf _
+ "<!--" _
+ ControlChars.CrLf _
+ divID + ".scrollTop = " + _scrollPos(0) + ";" _
+ ControlChars.CrLf _
+ divID + ".scrollLeft = " + _scrollPos(1) + ";" _
+ ControlChars.CrLf _
+ "//-->" _
+ ControlChars.CrLf _
+ "</script>"
Page.RegisterStartupScript(Me.ID, script)
script = Nothing
End Sub
This method writes script to the page just before the </form>
tag for each XTable
on the page. It sets the scrollTop
and scrollLeft
values on the basis of the information posted back from the hidden control. The scroll position is now preserved through postback. Of course, this method is only called if there is a value for OuterHeight
or OuterWidth
. If both of these properties are left empty, no containing <div>
is rendered at all, since it isn't relevant.
Always compile with OptionExplicit and OptionStrict "ON"
I learned the hard way that if you don't compile with OptionExplicit
and OptionStrict
both ON and the control is used in a project that does have these set to ON, the control is likely to crash. The code example I've posted was compiled with both of these options ON.
Known issues
Because the <select>
tag is a windowed control, it renders last. That means that its z-index is always going to be greater than the one(s) you assign to your table cells. Which means that when you scroll, dropdowns and listboxes will pass over the headers, rather than under.
The only solutions I've seen to this problem are:
- don't use
<select>
in these tables,
- use some script to render the
<select>
invisible when it's encroaching on frozen cells, or
- live with the minor graphical glitch.
Personally, I'm going to go with (c).