Expanding

Expanding

Expanding shows and hides extra rows below a row. It covers two things at once: opening a parent row to reveal its child rows, and opening any row to reveal a detail panel of your own making. This page ports TanStack Table's Expanding Feature (React) Guide.

Expanding is the fifth stage of the row model pipeline. The expanded stage inserts the sub-rows of every expanded row into the row list, so what comes out is a flat list you can render one tr at a time. Group rows produced by Grouping are ordinary expandable rows, so the same toggle opens and closes a group.

Sub-rows as expanded data

Sub-rows come from your data. Tell the core row model how to reach a row's children with withSubRows, and every row gets its children as sub-rows all the way down:

config : Table.Config Node
config =
    Table.config columns
        |> Table.withSubRows (nodeFields >> .children)
        |> Table.withGetRowId (\node _ _ -> (nodeFields node).id)

withSubRows runs for every row and every sub-row, so keep it cheap. Elm has no recursive type alias, so a nested record needs a custom type:

nodeFields : Node -> { id : String, name : String, salary : Float, children : List Node }
nodeFields (Node fields) =
    fields

A detail panel instead of sub-rows

getCanExpand is False for a row with no sub-rows. Override it with withRowCanExpand when a row's expanded content is not table rows at all but something you render yourself:

detailConfig : Table.Config Person
detailConfig =
    Table.config
        [ Table.column "firstName" (.firstName >> Value.String)
        , Table.column "lastName" (.lastName >> Value.String)
        ]
        |> Table.withGetRowId (\person _ _ -> person.id)
        |> Table.withRowCanExpand (\_ -> True)

Then render the panel as a second tr keyed off getIsExpanded. The package has nothing to do with what goes inside it:

viewRowAndPanel : Table.State -> Table.Row Person -> List (Html Msg)
viewRowAndPanel state row =
    let
        cells : List Table.Cell
        cells =
            Table.visibleCells detailConfig state row
    in
    Html.tr [ onClick (ExpandToggled (Table.rowId row)) ]
        (List.map (\cell -> Html.td [] [ Html.text (Value.toString cell.value) ]) cells)
        :: (if Table.getIsExpanded detailConfig state row then
                [ Html.tr []
                    [ Html.td [ colspan (List.length cells) ]
                        [ Html.text ("Anything you like about " ++ (Table.rowOriginal row).firstName) ]
                    ]
                ]

            else
                []
           )

State

Expanding owns one state slice. Expanded is an abstract type with two cases, standing in for TanStack's true | Record<string, boolean>:

-- in Table.State
expanded : Expanded

-- in Table.initialState
expanded = Table.expandedIds Set.empty
Value Meaning
expandAll Every row is expanded. TanStack's expanded: true.
expandedIds Only the rows whose ids are in this Set String are expanded.

Read the slice back with expandedIdsOf, which is Nothing for expandAll:

expandedRowIds : Table.State -> Maybe (Set String)
expandedRowIds state =
    Table.expandedIdsOf state.expanded

Writing the slice directly is setExpanded:

expandEverything : Table.State -> Table.State
expandEverything =
    Table.setExpanded Table.expandAll
expandOnly : List String -> Table.State -> Table.State
expandOnly rowIds =
    Table.setExpanded (Table.expandedIds (Set.fromList rowIds))

Config options

Option Type Default Description
enableExpanding Bool True Turns expanding off for the whole table.
getSubRows row -> List row \_ -> [] Where a row's children come from. Set it with withSubRows.
getRowCanExpand Maybe (Row row -> Bool) Nothing A per-row override for "can this row expand?". Set it with withRowCanExpand. It wins over enableExpanding and over the "has sub-rows" rule.
getIsRowExpanded Maybe (Row row -> Bool) Nothing A per-row override for "is this row expanded?". Set it with withIsRowExpanded. It wins over State.expanded outright.
paginateExpandedRows Bool True With it False, expanded sub-rows always render on their parent's page, so a page can hold more rows than the page size. See Pagination.
manualExpanding Bool False Skip the expanded stage. expandedRowModel returns its input unchanged, and the rows you pass in are assumed to be expanded already.

With paginateExpandedRows = False the expanded stage is a pass-through and the sub-rows are inserted by the paginated stage instead, after the page has been sliced. That is what puts a row's descendants on the same page as the row, and it is why rowsInDisplayOrder, not the page's .rows, is what you render. The one exception is manualPagination = True: with no page slice to run in, the expansion goes back to the expanded stage, matching TanStack.

Two options on the filtering side change which rows survive to be expanded: filterFromLeafRows makes a parent survive when any descendant matches, and maxLeafRowFilterDepth bounds how deep that search goes. Both are described in Column Filtering.

Column options

Expanding has no column builders. Sub-rows are a property of the data, so they are configured on the Config with withSubRows, not per column.

Transitions

Function Signature Description
toggleExpanded Config row -> RowModel row -> Row row -> Maybe Bool -> State -> State Expand or collapse one row. Nothing toggles it.
toggleAllRowsExpanded Config row -> RowModel row -> Maybe Bool -> State -> State Expand or collapse every row. Nothing toggles on getIsAllRowsExpanded.
setExpanded Expanded -> State -> State Replace the slice.
resetExpanded State -> State Collapse everything, back to no ids.

toggleExpanded takes a RowModel because it has to materialise expandAll into the ids of the rows that can expand before it can collapse one of them. Give it the pre-expanded model, which is the sorted one:

preExpanded : Table.State -> Table.RowModel Node
preExpanded state =
    Table.coreRowModelFromList config state nodes
        |> Table.filteredRowModel config state
        |> Table.groupedRowModel config state
        |> Table.preExpandedRowModel config state
update : Table.RowModel Node -> Msg -> Table.State -> Table.State
update model msg state =
    case msg of
        ExpandToggled rowId ->
            case Table.findRow model rowId of
                Just row ->
                    Table.toggleExpanded config model row Nothing state

                Nothing ->
                    state

        AllExpandedToggled ->
            Table.toggleAllRowsExpanded config model Nothing state

        ExpandedReset ->
            Table.resetExpanded state

Expanding a row that cannot expand is a no-op, and so is any request that matches the current state. Collapsing always applies, so a stale id can be cleaned up.

Queries

Function Signature Description
getCanExpand Config row -> Row row -> Bool Can this row expand?
getIsExpanded Config row -> State -> Row row -> Bool Is this row expanded?
getIsAllParentsExpanded Config row -> State -> RowModel row -> Row row -> Bool Is every ancestor of this row expanded? The row itself is not considered.
getCanSomeRowsExpand Config row -> RowModel row -> Bool Can any row of this model expand?
getIsSomeRowsExpanded State -> Bool Is any row expanded? expandAll counts.
getIsAllRowsExpanded Config row -> State -> RowModel row -> Bool Is every expandable row expanded? An empty slice is False.
getExpandedDepth Config row -> State -> RowModel row -> Int The deepest expanded row id, counted in .-separated segments.
preExpandedRowModel Config row -> State -> RowModel row -> RowModel row The model expansion runs on, which is the sorted one.

The package adds no toggle UI. Write the button yourself and hide it when the row cannot expand:

viewExpander : Table.State -> Table.Row Node -> Html Msg
viewExpander state row =
    if Table.getCanExpand config row then
        Html.button [ onClick (ExpandToggled (Table.rowId row)) ]
            [ Html.text
                (if Table.getIsExpanded config state row then
                    "-"

                 else
                    "+"
                )
            ]

    else
        Html.text ""

The table-wide controls read the same way:

viewToolbar : Table.State -> Table.RowModel Node -> Html Msg
viewToolbar state model =
    Html.div []
        [ Html.button [ onClick AllExpandedToggled ]
            [ Html.text
                (if Table.getIsAllRowsExpanded config state model then
                    "Collapse all"

                 else
                    "Expand all"
                )
            ]
        , Html.button [ onClick ExpandedReset ] [ Html.text "Reset" ]
        , Html.text
            (String.fromInt (Table.getExpandedDepth config state model) ++ " levels open")
        ]

TanStack reads the pre-pagination row model for getCanSomeRowsExpand so a control can reflect rows that are not on the current page. Give it the same model.

Not covered

Controlled state through the atoms option or state.expanded plus onExpandedChange has nothing to port: State.expanded is always yours. row.getToggleExpandedHandler and table.getToggleAllRowsExpandedHandler are not ported either, because the package produces no event handlers.

autoResetExpanded and autoResetAll are not ported. Nothing recomputes behind your back, so collapse rows in the same update branch that changes the data or the grouping, if that is what you want.

Pinning and sorting expanded rows need nothing extra: they behave exactly as described in Row Pinning and Sorting.

Example

Expanding, ported from TanStack's Expanding example. For the detail-panel use, see Expanding Sub Components.