Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / HTML

The XTable Extended Table Server Control

4.00/5 (2 votes)
2 Aug 20055 min read 1   857  
A Table control that supports THead, TBody and TFoot tags, as well as frozen top- and left-headers.

Sample Image - xtable.gif

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:

VB
Protected Overrides Function CreateControlCollection() As ControlCollection
   Return New RowControlCollection(Me)
End Function 'CreateControlCollection

So the XTable class does this:

VB
Protected Overrides Function CreateControlCollection() As ControlCollection
   Return New RowGroupControlCollection(Me)
End Function 'CreateControlCollection

The Rows property of Table looks like this:

VB
<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 'Rows

So now the RowGroups property of XTable looks like this:

VB.NET
<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 'RowGroups

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:

VB
Public Sub New()
   MyBase.New(HtmlTextWriterTag.Tr)
End Sub 'New

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>:

VB
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 'EnableTHead

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:

VB
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 'RenderBeginTag
VB
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 'RenderEndTag

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:

VB
Protected Overrides Sub AddAttributesToRender(ByVal writer As HtmlTextWriter)
   '*********
   'Original Microsoft code
   '*********
   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 'AddAttributesToRender

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.

VB
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 'RegisterScript

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:

  1. don't use <select> in these tables,
  2. use some script to render the <select> invisible when it's encroaching on frozen cells, or
  3. live with the minor graphical glitch.

Personally, I'm going to go with (c).

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here