Sticky Column Pinning

Pinned columns stay put with position: sticky while the rest scroll. Ports examples/react/column-pinning-sticky from TanStack Table.

ColumnPinningSticky.elm
module ColumnPinningSticky exposing (main)

{-| Sticky Column Pinning.

Ports `examples/react/column-pinning-sticky/src/main.tsx` from TanStack
Table: one table whose pinned cells are `position: sticky`, offset with
`getColumnStart` on the left region and `getColumnAfter` on the right one,
with the inset box shadow on the last left-pinned and the first right-pinned
column. Each header also prints `columnIndex` inside its own region, which is
the React example's `column.getIndex(...)` demo.

The React example also has drag resize handles (`columnResizingFeature`).
Drag resizing is outside this package, so they are left out here; see the
Column Sizing example for the sizing APIs.

-}

import Browser
import Html exposing (Html, button, div, input, label, pre, span, table, tbody, td, text, th, thead, tr)
import Html.Attributes exposing (checked, class, classList, colspan, style, type_)
import Html.Events exposing (onCheck, onClick)
import Random
import Shared.People as People exposing (Person)
import Shared.StateJson as StateJson
import Table
import Table.Value as Value



-- COLUMNS


config : Table.Config Person
config =
    Table.config
        [ Table.column "firstName" (.firstName >> Value.String)
            |> Table.withHeader "First Name"
            |> Table.withSize 180
        , Table.column "lastName" (.lastName >> maybeString)
            |> Table.withHeader "Last Name"
            |> Table.withSize 180
        , Table.column "age" (.age >> toFloat >> Value.Number)
            |> Table.withHeader "Age"
            |> Table.withSize 180
        , Table.column "visits" (.visits >> maybeNumber)
            |> Table.withHeader "Visits"
            |> Table.withSize 180
        , Table.column "status" (.status >> People.statusToString >> Value.String)
            |> Table.withHeader "Status"
            |> Table.withSize 180
        , Table.column "progress" (.progress >> toFloat >> Value.Number)
            |> Table.withHeader "Profile Progress"
            |> Table.withSize 180
        ]
        |> Table.withGetRowId (\person _ _ -> person.id)


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
    , shuffleSeed : Random.Seed
    }


init : Model
init =
    { state = Table.initialState
    , data = People.makeData 42 [ 20 ]
    , seed = 42
    , shuffleSeed = Random.initialSeed 42
    }


type Msg
    = RegenerateData
    | StressTest
    | ShuffleColumns
    | ToggleColumn String Bool
    | ToggleAllColumns Bool
    | PinColumn String Table.ColumnPinPosition


update : Msg -> Model -> Model
update msg model =
    case msg of
        RegenerateData ->
            { model | seed = model.seed + 1, data = People.makeData (model.seed + 1) [ 20 ] }

        StressTest ->
            { model | seed = model.seed + 1, data = People.makeData (model.seed + 1) [ 1000 ] }

        ShuffleColumns ->
            let
                ( order, nextSeed ) =
                    shuffle model.shuffleSeed (List.map Table.columnId (Table.leafColumns config))
            in
            { model
                | state = Table.setColumnOrder order model.state
                , shuffleSeed = nextSeed
            }

        ToggleColumn columnId visible ->
            case Table.findColumn config columnId of
                Just column ->
                    { model | state = Table.toggleColumnVisibility config column (Just visible) model.state }

                Nothing ->
                    model

        ToggleAllColumns visible ->
            { model | state = Table.toggleAllColumnsVisible config (Just visible) model.state }

        PinColumn columnId position ->
            case Table.findColumn config columnId of
                Just column ->
                    { model | state = Table.pinColumn position column model.state }

                Nothing ->
                    model


shuffle : Random.Seed -> List a -> ( List a, Random.Seed )
shuffle seed items =
    Random.step (Random.list (List.length items) (Random.int 0 1000000)) seed
        |> Tuple.mapFirst
            (\keys ->
                List.map2 Tuple.pair keys items
                    |> List.sortBy Tuple.first
                    |> List.map Tuple.second
            )



-- VIEW


view : Model -> Html Msg
view model =
    let
        rowModel : Table.RowModel Person
        rowModel =
            Table.coreRowModelFromList config model.state model.data
    in
    div [ class "demo-root" ]
        [ viewColumnToggles model.state
        , div [ class "spacer-md" ] []
        , div [ class "button-row" ]
            [ button [ onClick RegenerateData ] [ text "Regenerate Data" ]
            , button [ onClick StressTest ] [ text "Stress Test (1k rows)" ]
            , button [ onClick ShuffleColumns ] [ text "Shuffle Columns" ]
            ]
        , div [ class "spacer-md" ] []
        , div [ class "sticky-table-container" ]
            [ table [ style "width" (px (Table.totalSize config model.state)) ]
                [ thead [] (List.map (viewHeaderRow model.state) (Table.headerGroups config model.state))
                , tbody [] (List.map (viewRow model.state) rowModel.rows)
                ]
            ]
        , pre [ class "state-dump" ] [ text (StateJson.dump model.state) ]
        ]


{-| The styles that make sticky column pinning work, straight from the React
example's `getCommonPinningStyles`.
-}
pinningStyles : Table.State -> Table.Column Person -> List (Html.Attribute Msg)
pinningStyles state column =
    let
        pinned : Table.ColumnPinPosition
        pinned =
            Table.columnIsPinned state column

        isLastLeft : Bool
        isLastLeft =
            pinned == Table.pinnedLeft && Table.columnIsLast config state Table.leftColumnsRegion column

        isFirstRight : Bool
        isFirstRight =
            pinned == Table.pinnedRight && Table.columnIsFirst config state Table.rightColumnsRegion column
    in
    [ classList
        [ ( "pinned-left", pinned == Table.pinnedLeft )
        , ( "pinned-right", pinned == Table.pinnedRight )
        ]
    , style "box-shadow"
        (if isLastLeft then
            "-4px 0 4px -4px gray inset"

         else if isFirstRight then
            "4px 0 4px -4px gray inset"

         else
            "none"
        )
    , style "left"
        (if pinned == Table.pinnedLeft then
            px (Table.getColumnStart config state Table.leftColumnsRegion column)

         else
            "auto"
        )
    , style "right"
        (if pinned == Table.pinnedRight then
            px (Table.getColumnAfter config state Table.rightColumnsRegion column)

         else
            "auto"
        )
    , style "opacity"
        (if pinned == Table.columnUnpinned then
            "1"

         else
            "0.95"
        )
    , style "position"
        (if pinned == Table.columnUnpinned then
            "relative"

         else
            "sticky"
        )
    , style "width" (px (Table.getColumnSize config state column))
    ]


px : Float -> String
px n =
    String.fromFloat n ++ "px"


viewColumnToggles : Table.State -> Html Msg
viewColumnToggles state =
    div [ class "checkbox-list" ]
        (label []
            [ input
                [ type_ "checkbox"
                , checked (Table.isAllColumnsVisible config state)
                , onCheck ToggleAllColumns
                ]
                []
            , text " Toggle All"
            ]
            :: List.map
                (\column ->
                    label []
                        [ input
                            [ type_ "checkbox"
                            , checked (Table.columnIsVisible state column)
                            , onCheck (ToggleColumn (Table.columnId column))
                            ]
                            []
                        , text (" " ++ Table.columnId column)
                        ]
                )
                (Table.leafColumns config)
        )


viewHeaderRow : Table.State -> Table.HeaderGroup Person -> Html Msg
viewHeaderRow state group =
    tr [] (List.map (viewHeaderCell state) group.headers)


viewHeaderCell : Table.State -> Table.Header Person -> Html Msg
viewHeaderCell state header =
    case Table.findColumn config (Table.headerColumnId header) of
        Nothing ->
            th [ colspan (Table.headerColSpan header) ] []

        Just column ->
            th (colspan (Table.headerColSpan header) :: pinningStyles state column)
                [ div []
                    [ text (headerLabel header ++ " ")
                    , span [ class "muted" ] [ text (String.fromInt (regionIndex state column)) ]
                    ]
                , if Table.columnCanPin config column then
                    viewPinActions state column

                  else
                    text ""
                ]


{-| `column.getIndex(column.getIsPinned() || 'center')` from the React example.
-}
regionIndex : Table.State -> Table.Column Person -> Int
regionIndex state column =
    let
        pinned : Table.ColumnPinPosition
        pinned =
            Table.columnIsPinned state column
    in
    if pinned == Table.pinnedLeft then
        Table.columnIndex config state Table.leftColumnsRegion column

    else if pinned == Table.pinnedRight then
        Table.columnIndex config state Table.rightColumnsRegion column

    else
        Table.columnIndex config state Table.centerColumnsRegion column


viewPinActions : Table.State -> Table.Column Person -> Html Msg
viewPinActions state column =
    let
        pinned : Table.ColumnPinPosition
        pinned =
            Table.columnIsPinned state column

        columnId : String
        columnId =
            Table.columnId column
    in
    div [ class "pin-actions" ]
        [ if pinned /= Table.pinnedLeft then
            button [ onClick (PinColumn columnId Table.pinnedLeft) ] [ text "<=" ]

          else
            text ""
        , if pinned /= Table.columnUnpinned then
            button [ onClick (PinColumn columnId Table.columnUnpinned) ] [ text "X" ]

          else
            text ""
        , if pinned /= Table.pinnedRight then
            button [ onClick (PinColumn columnId Table.pinnedRight) ] [ text "=>" ]

          else
            text ""
        ]


headerLabel : Table.Header Person -> String
headerLabel header =
    Table.findColumn config (Table.headerColumnId header)
        |> Maybe.andThen Table.columnHeader
        |> Maybe.withDefault (Table.headerColumnId header)


viewRow : Table.State -> Table.Row Person -> Html Msg
viewRow state row =
    tr [] (List.map (viewCell state) (Table.visibleCells config state row))


viewCell : Table.State -> Table.Cell -> Html Msg
viewCell state cell =
    case Table.findColumn config cell.columnId of
        Nothing ->
            td [] [ text (Value.toString cell.value) ]

        Just column ->
            td (pinningStyles state column) [ text (Value.toString cell.value) ]


main : Program () Model Msg
main =
    Browser.sandbox { init = init, update = update, view = view }