Cell Spanning
Cell spanning merges adjacent body cells into one rendered cell, the way
rowspan and colspan merge cells in a plain HTML table or a spreadsheet.
Row spans come from the data: adjacent rows that share a value in an opted-in
column merge into one vertically spanning cell. Column spans are declared per
row, for things like a full-width summary row. It is the port of TanStack
Table's Cell Spanning guide and its cellSpanningFeature.
Spans are always computed from the rows you are about to render, so sorting, filtering, pagination, and row pinning simply change which rows are adjacent and the spans follow.
State
None. Cell spanning stores nothing in Table.State, has no transitions, and
nothing to reset.
What it does have is an index. TanStack rebuilds getCellSpanIndex() behind
a memo; nothing here is memoized, and rebuilding the index once per cell
would be quadratic, so the index is a value you build and pass around.
cellSpanIndex : Config row -> State -> RowModel row -> CellSpanIndex
Build it once, from the row model you actually render, then read every cell's spans out of it. Row pinning is read off the state, so a run never crosses a pinned section boundary.
Config options
| Option | Type | Default | Description |
|---|---|---|---|
enableCellSpanning |
Bool |
True |
Allow spanning for the whole table. False makes every cell report a span of 1 and builds no index. |
Set it with
withCellSpanning.
spanningOff : Table.Config Person
spanningOff =
Table.withCellSpanning False config
Column options
| Option | Type | Default | Description |
|---|---|---|---|
enableCellSpanning |
Bool |
True |
Take this column out of spanning even when the table allows it. |
spanRows |
Maybe (SpanRows row) |
Nothing |
How this column merges adjacent rows. |
spanColumns |
Maybe (Row row -> Int) |
Nothing |
How many columns this column's cell covers in a given row. |
| Builder | Sets | Notes |
|---|---|---|
withEnableCellSpanning |
enableCellSpanning |
A column opting out wins over the table option. |
withSpanRows |
spanRows |
TanStack's spanRows: true: merge adjacent rows with equal values. |
withSpanRowsWhen |
spanRows |
The predicate form. |
withSpanColumns |
spanColumns |
The count is resolved per row. |
Row spanning per column
withSpanRows merges adjacent rows whose value for the column is equal.
columns : List (Table.Column Person)
columns =
[ Table.column "department" (.department >> Value.String)
|> Table.withHeader "Department"
|> Table.withSpanRows
, Table.column "firstName" (.firstName >> Value.String)
|> Table.withHeader "First name"
|> Table.withSpanColumns summarySpan
, Table.column "salary" (.salary >> Value.Number)
|> Table.withHeader "Salary"
|> Table.withEnableCellSpanning False
]
Null never merges, since a merged block of blanks reads as a rendering bug
and joins rows that have nothing to do with each other. Two other differences
from TanStack follow from Elm's == rather than JavaScript's Object.is:
NaN never merges with itself, and -0 merges with 0.
withSpanRowsWhen takes control of where a run ends. The run is anchored:
every candidate row is tested against the run's first row, which keeps runs
transitive by construction.
type alias RowSpanContext row =
{ anchorRow : Row row
, anchorValue : Value
, previousRow : Row row
, row : Row row
, value : Value
}
salaryBandColumn : Table.Column Person
salaryBandColumn =
Table.column "salary" (.salary >> Value.Number)
|> Table.withHeader "Salary"
|> Table.withSpanRowsWhen sameSalaryBand
sameSalaryBand : Table.RowSpanContext Person -> Bool
sameSalaryBand context =
band context.anchorValue == band context.value
TanStack's context also carries column and table. Neither exists here:
there is no table instance, and the column is fixed by the call site that
installed the predicate.
Column spanning and summary rows
withSpanColumns declares horizontal spans on the column that carries the
merged content. The count is measured in the order columns actually render,
so hidden columns are not counted and reordering is handled for you.
summarySpan : Table.Row Person -> Int
summarySpan row =
if (Table.rowOriginal row).department == "Summary" then
Table.spanAllColumns
else
1
spanAllColumns is the stand-in
for TanStack's Infinity. A span larger than the room available is clamped
to the end of the cell's pinned region, so it never crosses the boundary
between left-pinned, center, and right-pinned columns.
When a cell spans rows and columns at once, the merged block is a rectangle:
the anchor cell reports both spans and every other cell in it reports 0 on
at least one axis. Cells only join a vertical run when their column spans
match, so a full-width summary row never merges into the data run above it.
Transitions
None. Nothing about spanning is stored, so there is nothing to transition. The one thing to recompute is the index, which you rebuild whenever the rows you render change.
Queries
| Query | Type | Answers |
|---|---|---|
cellSpanIndex |
Config row -> State -> RowModel row -> CellSpanIndex |
The spans of the rows you are rendering. |
cellRowSpan |
CellSpanIndex -> Cell -> Int |
How many rows this cell spans: 1 normally, 0 when covered. |
cellColSpan |
CellSpanIndex -> Cell -> Int |
How many columns it spans, same convention. |
cellIsCovered |
CellSpanIndex -> Cell -> Bool |
Is it covered by another cell's span? |
cellSpanIndexRowIds |
CellSpanIndex -> List String |
The row ids the index was built from, in render order. |
cellSpanIndexRowSpans |
CellSpanIndex -> Dict String (List Int) |
The vertical runs per column id, indexed by render-order row position. |
columnCanSpan |
Config row -> Column row -> Bool |
Does this column take part in spanning? |
Only columns with at least one run longer than one row appear in
cellSpanIndexRowSpans; a missing column means every cell in it spans one
row.
spanningColumnIds : Table.State -> List String
spanningColumnIds state =
Table.rowsFromList config state people
|> Table.cellSpanIndex config state
|> Table.cellSpanIndexRowSpans
|> Dict.keys
Rendering spanned cells
A covered cell reports a span of 0 and must be skipped. Never render
rowspan="0": in HTML that means "span to the end of the row group", which
merges the cell down the whole tbody. cellIsCovered is the check for both
axes at once.
viewBody : Table.State -> Html msg
viewBody state =
let
rendered : Table.RowModel Person
rendered =
Table.rowsFromList config state people
index : Table.CellSpanIndex
index =
Table.cellSpanIndex config state rendered
in
tbody [] (List.map (viewRow state index) rendered.rows)
viewRow : Table.State -> Table.CellSpanIndex -> Table.Row Person -> Html msg
viewRow state index row =
tr [] (List.filterMap (viewCell index) (Table.visibleCells config state row))
viewCell : Table.CellSpanIndex -> Table.Cell -> Maybe (Html msg)
viewCell index cell =
if Table.cellIsCovered index cell then
Nothing
else
Just
(td
[ rowspan (Table.cellRowSpan index cell)
, colspan (Table.cellColSpan index cell)
]
[ text (Value.toString cell.value) ]
)
A cell whose row is not in the index reports 1. TanStack rejects a stale
row by object identity; the index here keys by row id, so a row that is no
longer rendered simply has no entry.
Spanning, sorting, filtering, and pagination
Spans are derived from the row model you pass, never stored, so every change to it recomputes them.
- Sorting changes adjacency. Sorting by the spanned column clusters equal values and gives the longest runs; sorting by another column usually breaks them apart.
- Filtering removes rows. When a filter takes out the middle of a run, the remaining neighbours become adjacent and merge.
- Pagination clips runs. A run never crosses a page boundary; the next page opens a fresh cell even when the value continues.
- Pinned rows render in their own sections, so a run never crosses a pinned section boundary either.
Selecting merged cells
Cell Selection composes with spanning. A selection
rectangle grows to enclose every merged cell it touches, so a merge is always
entirely selected or entirely unselected, subtractions included. Arrow-key
navigation treats a merge as one stop,
selectedCellCount counts it
once, and selectedCellIds
returns only the cells that render.
selectedCellRangesData
still returns the full row-major grid, because covered cells carry real
values.
The expansion happens when the bounds are derived, not when the selection is stored, so stored corners stay put while sorting, paging, or turning spanning off changes which cells merge.
Known limitations
- Row virtualization needs care: if a run's anchor row is scrolled out of the
rendered window, the covered rows render nothing. Read
cellSpanIndexRowSpansto find the anchor and render a clamped span at the top of the window. See Virtualization. - Grouped columns ignore
spanRows, since Grouping already collapses repeated values into group rows, and a group row never joins a run in any column. - Footer groups are unaffected by cell spanning.
Not ported
Nothing from TanStack's page is missing, but two things are shaped
differently: spanRows: true is withSpanRows and its predicate form is
withSpanRowsWhen, and the memoized table.getCellSpanIndex() is the
explicit cellSpanIndex value described above.
Example
Cell Spanning, ported from TanStack's Cell Spanning example.