Header Groups
A header group is one row of header cells. This page is the port of TanStack's Header Groups guide.
What are header groups?
Header groups are the <tr> elements of your <thead>. Most tables have one
of them. You get more than one when your columns are nested, because each
level of nesting needs its own header row.
Table.group is what nests columns. A group
column has no accessor and no cells; it exists to put a header above the
columns underneath it:
config : Table.Config Person
config =
Table.config
[ Table.column "id" (.id >> Value.String)
|> Table.withHeader "#"
, Table.group "name"
[ Table.column "firstName" (.firstName >> Value.String)
|> Table.withHeader "First"
, Table.column "lastName" (.lastName >> Value.String)
|> Table.withHeader "Last"
]
|> Table.withHeader "Name"
, Table.group "work"
[ Table.column "department" (.department >> Value.String)
|> Table.withHeader "Department"
, Table.column "salary" (.salary >> Value.Number)
|> Table.withHeader "Salary"
|> Table.withFooter "Total"
]
|> Table.withHeader "Work"
]
That config is three levels wide but two levels deep, so it produces two
header rows: # / Name / Work on top, then First / Last /
Department / Salary.
The id column is a leaf sitting at the top level, shallower than the
deepest leaf. To keep the grid rectangular, the header builder puts a
placeholder header above (or in the header rows alongside) each such
column. Placeholders are covered in Headers; the short
version is that you either render them as empty cells or merge them with
rowSpan.
Where to get header groups from
| TanStack | elm-table |
|---|---|
table.getHeaderGroups() |
headerGroups |
table.getFooterGroups() |
footerGroups |
table.getStartHeaderGroups() |
leftHeaderGroups |
table.getCenterHeaderGroups() |
centerHeaderGroups |
table.getEndHeaderGroups() |
rightHeaderGroups |
table.getStartFooterGroups() |
leftFooterGroups |
table.getCenterFooterGroups() |
centerFooterGroups |
table.getEndFooterGroups() |
rightFooterGroups |
Every one of them has the type Config row -> State -> List (HeaderGroup row). They need the State because column visibility, column order, and
column pinning all change which columns end up in which header row.
headerGroups already handles pinning: when any column is pinned it builds
the rows over the left-pinned columns, then the unpinned ones, then the
right-pinned ones, in that order. Reach for the three regional functions only
when you are laying the regions out as separate elements.
footerGroups is headerGroups reversed, bottom row first, matching
TanStack.
Header group objects
HeaderGroup row is a plain record, not an opaque type:
type alias HeaderGroup row =
{ id : String
, depth : Int
, headers : List (Header row)
}
idis built from the depth:"0","1", and so on. The pinned variants prefix it with their region, keeping TanStack'sstart/center/endwording, so the left-pinned top row is"start_0"and the right-pinned one is"end_0".depthis the row index among the header rows, zero for the top row.headersare the header cells of that row, left to right.
Access header cells
Map over headerGroup.headers. This renders a <thead> with one <tr> per
header group, merging the placeholder chains vertically with rowspan:
viewHead : Table.State -> Html msg
viewHead state =
thead [] (List.map viewHeaderRow (Table.headerGroups config state))
viewHeaderRow : Table.HeaderGroup Person -> Html msg
viewHeaderRow headerGroup =
tr [] (List.filterMap viewHeaderCell headerGroup.headers)
viewHeaderCell : Table.Header Person -> Maybe (Html msg)
viewHeaderCell header =
if Table.headerRowSpan header == 0 then
Nothing
else
Just
(th
[ colspan (Table.headerColSpan header)
, rowspan (Table.headerRowSpan header)
]
[ text (headerLabel header) ]
)
A header with a rowSpan of 0 is covered by a header above it and is
skipped, which is why viewHeaderRow uses List.filterMap. Everything else
is drawn with both colspan and rowspan.
A header carries no column, only headerColumnId, so the label comes from a
lookup:
headerLabel : Table.Header Person -> String
headerLabel header =
let
id : String
id =
Table.headerColumnId header
in
Table.findColumn config id
|> Maybe.andThen Table.columnHeader
|> Maybe.withDefault id
Footers
Footer rows come out bottom row first, which puts a spanning placeholder
below the cells it would have to cover. The rowSpan trick above does not
work there. In a <tfoot>, render placeholders as empty cells instead:
viewFoot : Table.State -> Html msg
viewFoot state =
tfoot [] (List.map viewFooterRow (Table.footerGroups config state))
viewFooterRow : Table.HeaderGroup Person -> Html msg
viewFooterRow headerGroup =
tr [] (List.map viewFooterCell headerGroup.headers)
viewFooterCell : Table.Header Person -> Html msg
viewFooterCell header =
if Table.headerIsPlaceholder header then
th [] []
else
th [ colspan (Table.headerColSpan header) ]
[ text (footerLabel header) ]
The footer text comes from
withFooter, read back with
columnFooter:
footerLabel : Table.Header Person -> String
footerLabel header =
Table.findColumn config (Table.headerColumnId header)
|> Maybe.andThen Table.columnFooter
|> Maybe.withDefault ""
Pinned regions
If you are drawing the three pinned regions separately, the regional functions return header rows of the same depth, so they zip together row by row:
viewPinnedHead : Table.State -> Html msg
viewPinnedHead state =
let
rowsOf : List (Table.HeaderGroup Person) -> List (List (Table.Header Person))
rowsOf groups =
List.map .headers groups
in
thead []
(List.map3
(\left center right -> tr [] (List.filterMap viewHeaderCell (left ++ center ++ right)))
(rowsOf (Table.leftHeaderGroups config state))
(rowsOf (Table.centerHeaderGroups config state))
(rowsOf (Table.rightHeaderGroups config state))
)
See Column Pinning for the state that drives this.
Nothing is memoized
Each call rebuilds the header rows from the Config and the State. Call
headerGroups once in your view and pass the result down, rather than
calling it inside a loop over rows.