Column Resizing
Drag a header edge to resize a column, with onChange and onEnd modes and left-to-right or right-to-left direction. Ports examples/react/column-resizing from TanStack Table.
ColumnResizing.elm
module ColumnResizing exposing (main)
{-| Column Resizing.
Ports `examples/react/column-resizing/src/main.tsx` from TanStack Table: a
grouped header tree whose every header carries a drag handle, the resize mode
select (`onChange` / `onEnd`), the resize direction select (`ltr` / `rtl`),
and the state dump.
The package has no DOM, so `header.getResizeHandler()` is split into the
three transitions the React handler performs internally:
- `Table.startColumnResize` on `mousedown` and `touchstart`,
- `Table.updateColumnResize` on every `mousemove` / `touchmove`,
- `Table.endColumnResize` on `mouseup`, `touchend` and `touchcancel`.
Mouse moves arrive through `Browser.Events` subscriptions, which are only
installed while a drag is running, because the pointer leaves the 5-pixel
handle immediately. Touch moves are listened for on the handle itself: a
touch gesture keeps firing at the element its `touchstart` hit.
-}
import Browser
import Browser.Events
import Html exposing (Html, button, div, h2, option, pre, select, table, tbody, td, text, th, thead, tr)
import Html.Attributes exposing (class, classList, colspan, selected, style, value)
import Html.Events exposing (on, onClick, onDoubleClick, preventDefaultOn)
import Json.Decode as Decode exposing (Decoder)
import Shared.People as People exposing (Person)
import Shared.StateJson as StateJson
import Table
import Table.Value as Value
-- COLUMNS
columns : List (Table.Column Person)
columns =
[ Table.group "name"
[ Table.column "firstName" (.firstName >> Value.String)
|> Table.withHeader "firstName"
|> Table.withFooter "firstName"
-- , Table.withEnableResizing False -- prevent this column from being resized
-- , Table.withSize 180 |> Table.withMinSize 80 |> Table.withMaxSize 400
, Table.column "lastName" (.lastName >> maybeString)
|> Table.withHeader "Last Name"
|> Table.withFooter "lastName"
]
|> Table.withHeader "Name"
|> Table.withFooter "name"
, Table.group "info"
[ Table.column "age" (.age >> toFloat >> Value.Number)
|> Table.withHeader "Age"
|> Table.withFooter "age"
, Table.group "moreInfo"
[ Table.column "visits" (.visits >> maybeNumber)
|> Table.withHeader "Visits"
|> Table.withFooter "visits"
, Table.column "status" (.status >> People.statusToString >> Value.String)
|> Table.withHeader "Status"
|> Table.withFooter "status"
, Table.column "progress" (.progress >> toFloat >> Value.Number)
|> Table.withHeader "Profile Progress"
|> Table.withFooter "progress"
]
|> Table.withHeader "More Info"
]
|> Table.withHeader "Info"
|> Table.withFooter "info"
]
{-| The mode and the direction are table options, so the config is rebuilt
from the model rather than being a constant.
-}
configFor : Model -> Table.Config Person
configFor model =
let
base : Table.Config Person
base =
Table.config columns
|> Table.withGetRowId (\person _ _ -> person.id)
in
{ base
| columnResizeMode = model.mode
, columnResizeDirection = model.direction
}
maybeString : Maybe String -> Value.Value
maybeString =
Maybe.map Value.String >> Maybe.withDefault Value.Null
maybeNumber : Maybe Int -> Value.Value
maybeNumber =
Maybe.map (toFloat >> Value.Number) >> Maybe.withDefault Value.Null
-- MODEL
type alias Model =
{ state : Table.State
, data : List Person
, seed : Int
, mode : Table.ColumnResizeMode
, direction : Table.ColumnResizeDirection
}
init : () -> ( Model, Cmd Msg )
init _ =
( { state = Table.initialState
, data = People.makeData 42 [ 10 ]
, seed = 42
, mode = Table.resizeOnChange
, direction = Table.resizeLtr
}
, Cmd.none
)
type Msg
= RegenerateData
| StressTest
| ModeChanged String
| DirectionChanged String
| ResizeStarted (Table.Header Person) Float
| ResizeMoved Float
| ResizeEnded Float
| ResizeReleased
| SizeReset String
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
( updateModel msg model, Cmd.none )
updateModel : Msg -> Model -> Model
updateModel msg model =
let
config : Table.Config Person
config =
configFor model
in
case msg of
RegenerateData ->
{ model | seed = model.seed + 1, data = People.makeData (model.seed + 1) [ 10 ] }
StressTest ->
{ model | seed = model.seed + 1, data = People.makeData (model.seed + 1) [ 100 ] }
ModeChanged raw ->
{ model
| mode =
if raw == "onEnd" then
Table.resizeOnEnd
else
Table.resizeOnChange
}
DirectionChanged raw ->
{ model
| direction =
if raw == "rtl" then
Table.resizeRtl
else
Table.resizeLtr
}
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
}
-- `touchend` carries no position, so the drag ends at the last
-- position `touchmove` reported, exactly as the source does.
ResizeReleased ->
{ model | state = Table.endColumnResize config model.state }
SizeReset columnId ->
{ model | state = Table.resetColumnSize columnId model.state }
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)
]
clientX : Decoder Float
clientX =
Decode.field "clientX" Decode.float
{-| `Math.round(event.touches[0].clientX)`, the rounding the source applies
to a touch position.
-}
touchClientX : Decoder Float
touchClientX =
Decode.at [ "touches", "0", "clientX" ] Decode.float
|> Decode.map (round >> toFloat)
-- VIEW
view : Model -> Html Msg
view model =
let
config : Table.Config Person
config =
configFor model
rowModel : Table.RowModel Person
rowModel =
Table.coreRowModelFromList config model.state model.data
in
div [ class "demo-root" ]
[ div [ class "button-row" ]
[ button [ onClick RegenerateData ] [ text "Regenerate Data" ]
, button [ onClick StressTest ] [ text "Stress Test (100 rows)" ]
]
, div [ class "spacer-md" ] []
, div [ class "controls" ]
[ select [ class "outlined-control", onChange ModeChanged ]
[ option [ value "onEnd", selected (model.mode == Table.resizeOnEnd) ]
[ text "Resize: \"onEnd\"" ]
, option [ value "onChange", selected (model.mode == Table.resizeOnChange) ]
[ text "Resize: \"onChange\"" ]
]
, select [ class "outlined-control", onChange DirectionChanged ]
[ option [ value "ltr", selected (model.direction == Table.resizeLtr) ]
[ text "Resize Direction: \"ltr\"" ]
, option [ value "rtl", selected (model.direction == Table.resizeRtl) ]
[ text "Resize Direction: \"rtl\"" ]
]
]
, div [ style "direction" (directionName model.direction) ]
[ div [ class "spacer-md" ] []
, h2 [] [ text "<table/>" ]
, div [ class "scroll-container" ]
[ table [ style "width" (px (Table.centerTotalSize config model.state)) ]
[ thead []
(List.map (viewHeaderRow model config) (Table.headerGroups config model.state))
, tbody []
(List.map (viewRow model config) rowModel.rows)
]
]
, div [ class "spacer-md" ] []
, h2 [] [ text "<div/> (CSS variables)" ]
-- The same widths, written once as custom properties on the
-- container instead of on every cell. Elm sets them through the
-- `style` attribute as a whole, because `Html.Attributes.style`
-- assigns a style property, which custom properties are not.
, div [ class "scroll-container" ]
[ div
[ class "divTable"
, Html.Attributes.attribute "style" (sizeVariables model config)
]
[ div [ class "thead" ]
(List.map (viewVarHeaderRow model config) (Table.headerGroups config model.state))
, div [ class "tbody" ]
(List.map (viewVarRow config model.state) rowModel.rows)
]
]
]
, div [ class "spacer-md" ] []
, pre [ class "state-dump" ] [ text (StateJson.dump model.state) ]
]
viewHeaderRow : Model -> Table.Config Person -> Table.HeaderGroup Person -> Html Msg
viewHeaderRow model config group =
tr []
(List.map
(\header ->
th
[ colspan (Table.headerColSpan header)
, style "width" (px (Table.getHeaderSize config model.state header))
]
[ text (headerLabel config header)
, resizer model config header
]
)
group.headers
)
viewRow : Model -> Table.Config Person -> Table.Row Person -> Html Msg
viewRow model config row =
tr []
(List.map
(\cell ->
td [ style "width" (px (columnSize config model.state cell.columnId)) ]
[ text (Value.toString cell.value) ]
)
(Table.getAllCells config model.state row)
)
viewVarHeaderRow : Model -> Table.Config Person -> Table.HeaderGroup Person -> Html Msg
viewVarHeaderRow model config group =
div [ class "tr" ]
(List.map
(\header ->
div
[ class "th"
, style "width" (varWidth "header" (Table.headerId header))
]
[ text (headerLabel config header)
, resizer model config header
]
)
group.headers
)
viewVarRow : Table.Config Person -> Table.State -> Table.Row Person -> Html Msg
viewVarRow config state row =
div [ class "tr" ]
(List.map
(\cell ->
div
[ class "td"
, style "width" (varWidth "col" cell.columnId)
]
[ text (Value.toString cell.value) ]
)
(Table.getAllCells config state row)
)
{-| The drag handle. `mousedown` and `touchstart` both start the drag;
`touchmove` and `touchend` stay on the handle because a touch gesture keeps
firing at the element it started on. In `onEnd` mode the handle is offset by
the running `deltaOffset`, which is the preview line the React example draws.
-}
resizer : Model -> Table.Config Person -> Table.Header Person -> Html Msg
resizer model config header =
if not (Table.headerCanResize config header) then
text ""
else
div
[ class "resizer"
, class (directionName model.direction)
, classList [ ( "isResizing", Table.headerIsResizing model.state header ) ]
, style "transform" (previewOffset model header)
, onDoubleClick (SizeReset (Table.headerColumnId header))
, on "mousedown" (Decode.map (ResizeStarted header) clientX)
, on "touchstart" (Decode.map (ResizeStarted header) touchClientX)
, preventDefaultOn "touchmove" (Decode.map (\x -> ( ResizeMoved x, True )) touchClientX)
, on "touchend" (Decode.succeed ResizeReleased)
, on "touchcancel" (Decode.succeed ResizeReleased)
]
[]
previewOffset : Model -> Table.Header Person -> String
previewOffset model header =
if model.mode == Table.resizeOnEnd && Table.headerIsResizing model.state header then
let
sign : Float
sign =
if model.direction == Table.resizeRtl then
-1
else
1
in
"translateX("
++ px (sign * Maybe.withDefault 0 model.state.columnResizing.deltaOffset)
++ ")"
else
""
{-| `--header-<id>-size` and `--col-<id>-size` for every header, the way the
performant example writes them, as one style attribute.
-}
sizeVariables : Model -> Table.Config Person -> String
sizeVariables model config =
let
forHeader : Table.Header Person -> List String
forHeader header =
[ "--header-" ++ Table.headerId header ++ "-size:" ++ px (Table.getHeaderSize config model.state header)
, "--col-" ++ Table.headerColumnId header ++ "-size:" ++ px (columnSize config model.state (Table.headerColumnId header))
]
in
String.join ";"
(("width:" ++ px (Table.totalSize config model.state))
:: List.concatMap forHeader (Table.flatHeaders config model.state)
)
varWidth : String -> String -> String
varWidth prefix id =
"var(--" ++ prefix ++ "-" ++ id ++ "-size)"
onChange : (String -> Msg) -> Html.Attribute Msg
onChange toMsg =
on "change" (Decode.map toMsg (Decode.at [ "target", "value" ] Decode.string))
directionName : Table.ColumnResizeDirection -> String
directionName direction =
if direction == Table.resizeRtl then
"rtl"
else
"ltr"
px : Float -> String
px n =
String.fromFloat n ++ "px"
columnSize : Table.Config Person -> Table.State -> String -> Float
columnSize config state columnId =
Table.findColumn config columnId
|> Maybe.map (Table.getColumnSize config state)
|> Maybe.withDefault 0
headerLabel : Table.Config Person -> Table.Header Person -> String
headerLabel config header =
if Table.headerIsPlaceholder header then
""
else
Table.findColumn config (Table.headerColumnId header)
|> Maybe.andThen Table.columnHeader
|> Maybe.withDefault (Table.headerColumnId header)
main : Program () Model Msg
main =
Browser.element
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
}