Column Resizing
Column Resizing
Column resizing is dragging a column edge to a new width. The package holds
the transient state of the drag, does the arithmetic, and writes the widths it
computes into State.columnSizing; the handle, the pointer events, and the
subscription are yours, because nothing here touches the DOM. This page ports
TanStack Table's Column Resizing (React) Guide.
Resizing builds on Column Sizing. If you only need starting, minimum and maximum widths, that page is enough on its own.
Enable column resizing
Every column can be resized by default. Turn it off for the whole table with
Config.enableColumnResizing, or per column with
withEnableResizing:
config : Table.Config Person
config =
let
base : Table.Config Person
base =
Table.config
[ Table.column "firstName" (.firstName >> Value.String)
|> Table.withHeader "First name"
, Table.column "lastName" (.lastName >> Value.String)
|> Table.withHeader "Last name"
, Table.column "age" (.age >> toFloat >> Value.Number)
|> Table.withHeader "Age"
|> Table.withEnableResizing False
]
|> Table.withDefaultColumn { size = 150, minSize = 60, maxSize = 800 }
in
{ base
| enableColumnResizing = True
, columnResizeMode = Table.resizeOnChange
, columnResizeDirection = Table.resizeLtr
}
Ask columnCanResize or, on a
header, headerCanResize before
rendering a handle. Both are True only when the column and the table allow
it.
Column resize mode
Config.columnResizeMode decides when a drag writes to
State.columnSizing.
| Mode | What happens |
|---|---|
resizeOnChange |
Every pointer move commits the new widths, so the table follows the pointer. The default here. |
resizeOnEnd |
Moves only track the drag in State.columnResizing; the widths are committed when the drag ends. Draw an indicator from deltaOffset while it runs. |
onEndConfig : Table.Config Person -> Table.Config Person
onEndConfig cfg =
{ cfg | columnResizeMode = Table.resizeOnEnd }
The default is resizeOnEnd, as in TanStack, where an "onChange" drag
re-renders a whole React table on every frame. Elm's virtual DOM diff makes
that much cheaper, so resizeOnChange is a reasonable choice for most tables;
keep resizeOnEnd for tables with expensive cells.
Column resize direction
Config.columnResizeDirection is the sign of the drag.
resizeLtr, the default, grows a column
when the pointer moves right; resizeRtl
grows it when the pointer moves left, which is what a right-to-left layout
needs:
rtlConfig : Table.Config Person -> Table.Config Person
rtlConfig cfg =
{ cfg | columnResizeDirection = Table.resizeRtl }
State
Resizing owns one state slice, and it is transient: it is empty between drags.
-- in Table.State
columnResizing : ColumnResizingState
-- in Table.initialState
columnResizing =
{ columnSizingStart = []
, deltaOffset = Nothing
, deltaPercentage = Nothing
, isResizingColumn = Nothing
, startOffset = Nothing
, startSize = Nothing
}
| Field | What it holds |
|---|---|
columnSizingStart |
The width every leaf header under the dragged header had when the drag began, as ( column id, width ). |
deltaOffset |
How far the pointer has moved from startOffset, with the direction's sign. |
deltaPercentage |
That distance as a fraction of startSize, floored at -0.999999. |
isResizingColumn |
The column being dragged. TanStack's false is Nothing here. |
startOffset |
The pointer position the drag started at. |
startSize |
The width of the dragged header when the drag started. |
The committed widths live in State.columnSizing, not here. See
Column Sizing.
Transitions
TanStack does the whole drag inside the closure header.getResizeHandler()
returns, which installs its own document listeners. That closure is three
state transitions here, and you decide which events call them.
| Function | Signature | Call it on |
|---|---|---|
startColumnResize |
Config row -> State -> Header row -> Float -> State |
mousedown and touchstart, with the event's clientX. |
updateColumnResize |
Config row -> Float -> State -> State |
Every mousemove and touchmove, with the event's clientX. |
endColumnResize |
Config row -> State -> State |
mouseup, touchend, and touchcancel. |
startColumnResize records startOffset, startSize, the leaf widths, and
the column id, and does nothing at all for a column that cannot be resized.
updateColumnResize writes deltaOffset and deltaPercentage, and in
resizeOnChange mode the new widths as well. endColumnResize commits the
widths the last move computed and then clears the slice, so pass the ending
position through updateColumnResize first when the event carries one, as a
mouseup does. A touchend carries no position, so calling endColumnResize
alone commits the last position a move reported.
Two more transitions cover the rest of TanStack's table API:
setColumnResizing replaces the
slice, and resetColumnResizing
empties it, which is table.resetHeaderSizeInfo(true).
update : Msg -> Model -> Model
update msg model =
case msg of
ResizeStarted header x ->
{ model | state = Table.startColumnResize config model.state header x }
ResizeMoved x ->
{ model | state = Table.updateColumnResize config x model.state }
ResizeEnded x ->
{ model
| state =
model.state
|> Table.updateColumnResize config x
|> Table.endColumnResize config
}
ResizeReleased ->
{ model | state = Table.endColumnResize config model.state }
SizeReset columnId ->
{ model | state = Table.resetColumnSize columnId model.state }
Connecting the transitions to your UI
Column size APIs
The widths a drag produces are read back with the column sizing queries, which is where the numbers for your markup come from:
Table.getHeaderSize config state header
Table.getColumnSize config state column
The resize handle
The handle is a small absolutely positioned element on the trailing edge of
the header cell. mousedown and touchstart both start the drag; the touch
decoder reads touches[0].clientX and rounds it, the way TanStack does:
resizeHandle : Table.State -> Table.Header Person -> Html Msg
resizeHandle state header =
if not (Table.headerCanResize config header) then
text ""
else
div
[ class "resizer"
, classList [ ( "is-resizing", Table.headerIsResizing state header ) ]
, Html.Events.onDoubleClick (SizeReset (Table.headerColumnId header))
, Html.Events.on "mousedown" (Decode.map (ResizeStarted header) clientX)
, Html.Events.on "touchstart" (Decode.map (ResizeStarted header) touchClientX)
, Html.Events.preventDefaultOn "touchmove"
(Decode.map (\x -> ( ResizeMoved x, True )) touchClientX)
, Html.Events.on "touchend" (Decode.succeed ResizeReleased)
, Html.Events.on "touchcancel" (Decode.succeed ResizeReleased)
]
[]
clientX : Decode.Decoder Float
clientX =
Decode.field "clientX" Decode.float
touchClientX : Decode.Decoder Float
touchClientX =
Decode.at [ "touches", "0", "clientX" ] Decode.float
|> Decode.map (round >> toFloat)
A double click on the handle is the usual way to give a column its default
width back, which is
resetColumnSize.
The header cell itself takes its width from
getHeaderSize:
viewHeaderCell : Table.State -> Table.Header Person -> Html Msg
viewHeaderCell state header =
th
[ style "width" (String.fromFloat (Table.getHeaderSize config state header) ++ "px")
, style "position" "relative"
]
[ text (Maybe.withDefault "" (headerText header))
, resizeHandle state header
]
Following the pointer
A mouse leaves the five-pixel handle on the first move, so the moves have to
be listened for on the document. Browser.Events does that, and the
subscription only exists while isResizingColumn is set:
subscriptions : Model -> Sub Msg
subscriptions model =
case model.state.columnResizing.isResizingColumn of
Nothing ->
Sub.none
Just _ ->
Sub.batch
[ Browser.Events.onMouseMove (Decode.map ResizeMoved clientX)
, Browser.Events.onMouseUp (Decode.map ResizeEnded clientX)
]
Touch events need no subscription: a touch gesture keeps firing at the element
its touchstart hit, so touchmove, touchend and touchcancel stay on the
handle, as they are in the snippet above.
A resize indicator
In resizeOnEnd mode the widths do not move until the drag ends, so the
feedback during the drag is an indicator offset by deltaOffset:
resizeIndicator : Table.State -> Table.Header Person -> Html msg
resizeIndicator state header =
let
offset : Float
offset =
if Table.headerIsResizing state header then
Maybe.withDefault 0 state.columnResizing.deltaOffset
else
0
in
div
[ class "resize-indicator"
, style "transform" ("translateX(" ++ String.fromFloat offset ++ "px)")
]
[]
headerIsResizing and
columnIsResizing answer "is this
the column being dragged?", which is also what a highlighted handle keys off.
Resizing performance
TanStack's advanced section is about keeping a drag off React's render path. The same shape works here, and buys more than it costs on a large table:
Write the widths as CSS custom properties once. Put
--header-<id>-sizeand--col-<id>-sizeon the table element, and give every cellwidth: calc(var(--col-firstName-size) * 1px). Only one attribute on one element changes per drag frame.Html.Attributes.stylesets a style property, which a custom property is not, so write the whole attribute withHtml.Attributes.attribute:
sizeVariables : Table.State -> String sizeVariables state = let forHeader : Table.Header Person -> List String forHeader header = [ "--header-" ++ Table.headerId header ++ "-size:" ++ px (Table.getHeaderSize config state header) , "--col-" ++ Table.headerColumnId header ++ "-size:" ++ px (columnWidth state (Table.headerColumnId header)) ] in String.join ";" (("width:" ++ px (Table.totalSize config state)) :: List.concatMap forHeader (Table.flatHeaders config state) )
2. **Keep the body out of the drag.** Once the cells read their widths from
the variables, the body markup no longer mentions the state, so
`Html.Lazy` skips rebuilding it entirely while a column is dragged:
```elm
viewTable : Model -> Html Msg
viewTable model =
table [ attribute "style" (sizeVariables model.state) ]
[ Html.thead []
(List.map
(\group -> Html.tr [] (List.map (viewHeaderCell model.state) group.headers))
(Table.headerGroups config model.state)
)
, Html.Lazy.lazy viewBody model.data
]
Elm does not have React's problem in the first place: an unchanged subtree
diffs to no DOM patches, so a resizeOnChange drag on an ordinary table is
already smooth without any of this.
Not covered
header.getResizeHandler()itself, and everything it installs: the document listeners, their removal, the passive-listener check, the multi-touch guard, and therequestAnimationFramecoalescing of pointer moves. Those are the event plumbing above, and Elm's subscriptions already deliver at most one message per frame's worth of pointer moves.- Owning
columnResizingthrough theatomsoption or throughstate.columnResizingplusonColumnResizingChange. The wholeStateis already yours, held in your own model. table.resetHeaderSizeInfo()with no argument, which restorestable.initialState. There is no table instance to hold an initial state; keep the slice you want to go back to and pass it tosetColumnResizing.
Example
Column Resizing, with both modes and both directions, and Performant Column Resizing, which renders the body from the CSS variables above.