Header Groups
Nested column groups rendered as multi-row headers and footers. Ports examples/react/header-groups from TanStack Table.
HeaderGroups.elm
module HeaderGroups exposing (main)
{-| Ports `examples/react/header-groups/src/main.tsx`.
Four column trees over the same five rows:
1. an even tree of one group per pair of leaf columns,
2. groups nested inside groups, still even,
3. a tree that forces placeholder headers,
4. an uneven tree, where the placeholder at the top of a chain carries the
chain's `rowSpan` and the headers it covers report `0`.
-}
import Browser
import Html exposing (Html, button, div, h2, section, table, tbody, td, text, tfoot, th, thead, tr)
import Html.Attributes exposing (class, colspan, rowspan)
import Html.Events exposing (onClick)
import Shared.People as People exposing (Person)
import Table
import Table.Value as Value exposing (Value)
-- COLUMN TREES
{-| A traditional header group setup: every leaf column sits under a
top-level group, so the tree is even (2 header rows) and no placeholder
headers are created.
-}
basicConfig : Table.Config Person
basicConfig =
Table.config
[ Table.group "name"
[ firstName |> Table.withHeader "First Name" |> Table.withFooter "First Name"
, lastName |> Table.withHeader "Last Name" |> Table.withFooter "Last Name"
]
|> Table.withHeader "Name"
, Table.group "stats"
[ age |> Table.withHeader "Age" |> Table.withFooter "Age"
, visits |> Table.withHeader "Visits" |> Table.withFooter "Visits"
]
|> Table.withHeader "Stats"
, Table.group "profile"
[ status |> Table.withHeader "Status" |> Table.withFooter "Status"
, progress
|> Table.withHeader "Profile Progress"
|> Table.withFooter "Profile Progress"
]
|> Table.withHeader "Profile"
]
|> withRowId
{-| Groups nested inside groups, with every leaf column at the same depth.
The tree stays even, so there are three header rows and still no
placeholders, and each group's colSpan is the sum of its descendants.
-}
nestedConfig : Table.Config Person
nestedConfig =
Table.config
[ Table.group "person"
[ Table.group "name"
[ firstName |> Table.withHeader "First Name"
, lastName |> Table.withHeader "Last Name"
]
|> Table.withHeader "Name"
, Table.group "demographics"
[ age |> Table.withHeader "Age" ]
|> Table.withHeader "Demographics"
]
|> Table.withHeader "Person"
, Table.group "activity"
[ Table.group "engagement"
[ visits |> Table.withHeader "Visits"
, status |> Table.withHeader "Status"
]
|> Table.withHeader "Engagement"
, Table.group "progressGroup"
[ progress |> Table.withHeader "Profile Progress" ]
|> Table.withHeader "Progress"
]
|> Table.withHeader "Activity"
]
|> withRowId
{-| `More Info` sits one level below `Age`, so the header row above `Age`'s
siblings holds placeholder headers.
-}
placeholderConfig : Table.Config Person
placeholderConfig =
Table.config
[ Table.group "hello"
[ firstName |> Table.withFooter "firstName"
, lastName |> Table.withHeader "Last Name" |> Table.withFooter "lastName"
]
|> Table.withHeader "Hello"
, Table.group "Info"
[ age |> Table.withHeader "Age" |> Table.withFooter "age"
, Table.group "More Info"
[ visits |> Table.withHeader "Visits" |> Table.withFooter "visits"
, status |> Table.withHeader "Status" |> Table.withFooter "status"
, progress
|> Table.withHeader "Profile Progress"
|> Table.withFooter "progress"
]
|> Table.withHeader "More Info"
]
|> Table.withHeader "Info"
|> Table.withFooter "Info"
]
|> withRowId
{-| An uneven column tree: `fullName` and `progress` are top-level leaf
columns while their siblings nest two and three levels deep.
-}
unevenConfig : Table.Config Person
unevenConfig =
Table.config
[ Table.column "fullName" fullNameValue
|> Table.withHeader "Full Name"
, Table.group "Info"
[ age |> Table.withHeader "Age"
, Table.group "More Info"
[ visits |> Table.withHeader "Visits"
, status |> Table.withHeader "Status"
]
|> Table.withHeader "More Info"
]
|> Table.withHeader "Info"
, progress |> Table.withHeader "Profile Progress"
]
|> withRowId
withRowId : Table.Config Person -> Table.Config Person
withRowId =
Table.withGetRowId (\person _ _ -> person.id)
firstName : Table.Column Person
firstName =
Table.column "firstName" (.firstName >> Value.String)
lastName : Table.Column Person
lastName =
Table.column "lastName" (.lastName >> maybeString)
age : Table.Column Person
age =
Table.column "age" (.age >> toFloat >> Value.Number)
visits : Table.Column Person
visits =
Table.column "visits" (.visits >> maybeNumber)
status : Table.Column Person
status =
Table.column "status" (.status >> People.statusToString >> Value.String)
progress : Table.Column Person
progress =
Table.column "progress" (.progress >> toFloat >> Value.Number)
fullNameValue : Person -> Value
fullNameValue person =
Value.String
(String.join " "
(List.filter (\part -> part /= "")
[ person.firstName, Maybe.withDefault "" person.lastName ]
)
)
maybeString : Maybe String -> Value
maybeString =
Maybe.map Value.String >> Maybe.withDefault Value.Null
maybeNumber : Maybe Int -> Value
maybeNumber =
Maybe.map (toFloat >> Value.Number) >> Maybe.withDefault Value.Null
-- MODEL
type alias Model =
{ state : Table.State
, seed : Int
, data : List Person
}
init : Model
init =
{ state = Table.initialState
, seed = 42
, data = People.makeData 42 [ 5 ]
}
type Msg
= RegenerateData
update : Msg -> Model -> Model
update msg model =
case msg of
RegenerateData ->
let
seed : Int
seed =
model.seed + 1
in
{ model | seed = seed, data = People.makeData seed [ 5 ] }
-- VIEW
type Footers
= NoFooters
| AllFooters
| FootersWithText
view : Model -> Html Msg
view model =
div [ class "demo-root" ]
[ div [ class "button-row" ]
[ button [ onClick RegenerateData ] [ text "Regenerate Data" ] ]
, div [ class "spacer-md" ] []
, div [ class "example-grid" ]
[ panel "Basic Header Groups" basicConfig FootersWithText False model
, panel "Nested Header Groups" nestedConfig NoFooters False model
, panel "Placeholder Headers" placeholderConfig AllFooters False model
, panel "Header Row Spanning" unevenConfig NoFooters True model
]
, div [ class "spacer-md" ] []
]
panel : String -> Table.Config Person -> Footers -> Bool -> Model -> Html Msg
panel title config footers useRowSpan model =
section [ class "example-panel" ]
[ h2 [] [ text title ]
, viewTable config footers useRowSpan model
]
viewTable : Table.Config Person -> Footers -> Bool -> Model -> Html Msg
viewTable config footers useRowSpan model =
let
rowModel : Table.RowModel Person
rowModel =
Table.rowsFromList config model.state model.data
body : List (Html Msg)
body =
[ thead []
(List.map (viewHeaderRow config useRowSpan)
(Table.headerGroups config model.state)
)
, tbody [] (List.map (viewRow config model.state) rowModel.rows)
]
in
table []
(case footers of
NoFooters ->
body
AllFooters ->
body
++ [ tfoot []
(List.map (viewFooterRow config)
(Table.footerGroups config model.state)
)
]
FootersWithText ->
body
++ [ tfoot []
(Table.footerGroups config model.state
-- Only the leaf columns declare footers, so
-- skip the group row instead of rendering a
-- blank one.
|> List.filter (hasFooterText config)
|> List.map (viewFooterRow config)
)
]
)
hasFooterText : Table.Config Person -> Table.HeaderGroup Person -> Bool
hasFooterText config group =
List.any
(\header ->
not (Table.headerIsPlaceholder header)
&& footerText config header
/= ""
)
group.headers
viewHeaderRow : Table.Config Person -> Bool -> Table.HeaderGroup Person -> Html Msg
viewHeaderRow config useRowSpan group =
tr []
(group.headers
|> List.filter (\header -> not useRowSpan || Table.headerRowSpan header /= 0)
|> List.map (viewHeaderCell config useRowSpan)
)
viewHeaderCell : Table.Config Person -> Bool -> Table.Header Person -> Html Msg
viewHeaderCell config useRowSpan header =
th
(colspan (Table.headerColSpan header)
:: (if useRowSpan then
[ rowspan (Table.headerRowSpan header) ]
else
[]
)
)
[ if Table.headerIsPlaceholder header && not useRowSpan then
text ""
else
text (headerText config header)
]
viewFooterRow : Table.Config Person -> Table.HeaderGroup Person -> Html Msg
viewFooterRow config group =
tr [] (List.map (viewFooterCell config) group.headers)
viewFooterCell : Table.Config Person -> Table.Header Person -> Html Msg
viewFooterCell config header =
th [ colspan (Table.headerColSpan header) ]
[ if Table.headerIsPlaceholder header then
text ""
else
text (footerText config header)
]
headerText : Table.Config Person -> Table.Header Person -> String
headerText config header =
let
columnId : String
columnId =
Table.headerColumnId header
in
Table.findColumn config columnId
|> Maybe.andThen Table.columnHeader
|> Maybe.withDefault columnId
footerText : Table.Config Person -> Table.Header Person -> String
footerText config header =
Table.findColumn config (Table.headerColumnId header)
|> Maybe.andThen Table.columnFooter
|> Maybe.withDefault ""
viewRow : Table.Config Person -> Table.State -> Table.Row Person -> Html Msg
viewRow config state row =
tr []
(List.map (\cell -> td [] [ text (Value.toString cell.value) ])
(Table.getAllCells config state row)
)
main : Program () Model Msg
main =
Browser.sandbox { init = init, update = update, view = view }