Row Selection
Row selection keeps track of which rows a user has picked, by row id. It is
the port of TanStack Table's Row Selection guide and its
rowSelectionFeature. There is no feature to register here: every function
below is in Table, and the state slice is always present.
Because the selection is keyed by row id, give your rows meaningful ids with
Table.withGetRowId. Without it a row's id is its position in the data,
which stops meaning anything as soon as the data reorders.
config : Table.Config Person
config =
Table.config columns
|> Table.withGetRowId (\person _ _ -> person.id)
State
The slice is a Set of selected row ids.
-- in Table.State
, rowSelection : Set String
-- in Table.initialState
, rowSelection = Set.empty
Selecting a parent row writes the parent's id and the ids of its selectable
descendants into the set, so Table.selectedRowIds includes sub-rows
whenever enableSubRowSelection allows the descent. Deselecting one child
afterwards does not remove the parent id unless you ask for it; see
deselectParents under Transitions.
You own this state, the same way you own every other slice. See Table State for where to keep it.
Config options
| Option | Type | Default | Description |
|---|---|---|---|
enableRowSelection |
Row row -> Bool |
always True |
Can this row be selected at all. |
enableMultiRowSelection |
Row row -> Bool |
always True |
Can this row take part in a multi-row selection. Answer False for radio-button behaviour. |
enableSubRowSelection |
Row row -> Bool |
always True |
Does selecting this row also select its sub-rows. |
All three are predicates on the Row, not booleans. TanStack accepts
boolean | ((row) => boolean) for each; a single function type covers both,
and always False is the boolean form.
Enable row selection conditionally
Table.withRowSelection sets the first of the three.
activeRowsOnly : Table.Config Person
activeRowsOnly =
Table.withRowSelection (\row -> (Table.rowOriginal row).active) config
Use getCanSelect in your UI to
disable the checkbox of a row the predicate rejects.
Single row selection
Config is a plain record, so the other two options are a record update.
singleRowSelection : Table.Config Person
singleRowSelection =
{ config | enableMultiRowSelection = always False }
Sub-row selection
noSubRowSelection : Table.Config Person
noSubRowSelection =
{ config | enableSubRowSelection = always False }
When a parent blocks sub-row selection, toggleAllRowsSelected and
toggleAllPageRowsSelected skip that parent's descendants, and
getIsAllRowsSelected and getIsAllPageRowsSelected ignore them when
deciding whether everything is selected.
Column options
None. Row selection is a table-level feature; no with* builder on a column
affects it. A checkbox column is an ordinary
Table.display column that you render
yourself.
Transitions
| Transition | Type | What it does |
|---|---|---|
toggleRowSelected |
Config row -> RowModel row -> Row row -> Maybe Bool -> State -> State |
Select or deselect one row; Nothing flips it. Sub-rows follow along. |
toggleRowSelectedWith |
Config row -> SelectOptions -> RowModel row -> Row row -> Maybe Bool -> State -> State |
The same, with explicit SelectOptions. |
toggleAllRowsSelected |
Config row -> RowModel row -> Maybe Bool -> State -> State |
Select or deselect every row of the model you pass. |
toggleAllPageRowsSelected |
Config row -> RowModel row -> Maybe Bool -> State -> State |
The same for the current page; pass the paginated row model. |
selectRange |
Config row -> RowModel row -> String -> Row row -> Bool -> State -> State |
Set every row between an anchor id and this row to the given value. |
selectRangeWith |
Config row -> SelectOptions -> RowModel row -> String -> Row row -> Bool -> State -> State |
The same, with explicit SelectOptions. |
setRowSelection |
Set String -> State -> State |
Replace the selection. |
deselectAllRows |
State -> State |
Clear the selection, including ids of rows that can no longer be selected. |
resetRowSelection |
State -> State |
Clear the selection. |
SelectOptions is a plain record with a default:
type alias SelectOptions =
{ selectChildren : Bool
, deselectParents : Bool
}
-- Table.defaultSelectOptions
{ selectChildren = True, deselectParents = False }
selectChildren = False changes only the rows explicitly named, leaving
sub-rows alone. deselectParents = True removes ancestor ids when a row is
deselected, so a parent does not stay in the set after one of its children is
switched off.
pruneParents : Model -> RowClick -> Table.State
pruneParents model click =
Table.toggleRowSelectedWith config
{ selectChildren = True, deselectParents = True }
(prePaginated model.state)
click.row
(Just click.value)
model.state
Wiring a checkbox to a Msg
The message carries the row, the checkbox's new value, and whether Shift was held.
update : Msg -> Model -> Model
update msg model =
case msg of
ClickedSelectAll value ->
{ model
| state =
Table.toggleAllRowsSelected config
(prePaginated model.state)
(Just value)
model.state
, selectionAnchor = Nothing
}
ClickedRow click ->
{ model
| state = applyRowClick model click
, selectionAnchor = Just (Table.rowId click.row)
}
Shift range selection
Two things differ from TanStack here.
First, the anchor is your state. TanStack stores the last interacted row
id on the table instance as _lastSelectedRowId. There is no instance here,
so the anchor is a field in your model, and you decide when it moves and when
it clears.
init : Model
init =
{ state = Table.initialState
, selectionAnchor = Nothing
}
Second, the range is resolved against the pre-pagination rows in display
order. selectRange walks the row model you hand it, so pass the model
before the page slice (filtering, grouping, sorting, and expansion applied,
pagination not). A shift-click then covers rows on other pages, which is what
TanStack's getRowsInDisplayOrder does.
prePaginated : Table.State -> Table.RowModel Person
prePaginated state =
Table.coreRowModelFromList config state people
|> Table.filteredRowModel config state
|> Table.groupedRowModel config state
|> Table.sortedRowModel config state
|> Table.expandedRowModel config state
canSelectRange is the guard.
Both endpoints have to be in that display order and allow multi-selection.
When it answers False, fall back to an ordinary toggle, which is what the
handler below does.
applyRowClick : Model -> RowClick -> Table.State
applyRowClick model click =
let
rowModel : Table.RowModel Person
rowModel =
prePaginated model.state
anchorId : String
anchorId =
Maybe.withDefault "" model.selectionAnchor
in
if click.shift && Table.canSelectRange config model.state rowModel anchorId click.row then
Table.selectRange config rowModel anchorId click.row click.value model.state
else
Table.toggleRowSelected config rowModel click.row (Just click.value) model.state
Queries
| Query | Type | Answers |
|---|---|---|
getIsRowSelected |
State -> Row row -> Bool |
Is this row selected? |
getIsSomeSelected |
Config row -> State -> Row row -> Bool |
Is part, but not all, of this row's sub-tree selected? |
getIsAllSubRowsSelected |
Config row -> State -> Row row -> Bool |
Is this row's whole sub-tree selected? |
subRowSelection |
Config row -> State -> Row row -> SubRowSelection |
How much of the sub-tree is selected, as one value. |
getIsSomeRowsSelected |
State -> Bool |
Is anything selected at all? |
getIsAllRowsSelected |
Config row -> State -> RowModel row -> Bool |
Is every selectable row of that model selected? |
getIsAllPageRowsSelected |
Config row -> State -> RowModel row -> Bool |
The same for the current page. |
getIsSomePageRowsSelected |
Config row -> State -> RowModel row -> Bool |
Is any row of the current page selected or partly selected? |
getCanSelect |
Config row -> Row row -> Bool |
Can this row be selected? |
getCanSelectSubRows |
Config row -> Row row -> Bool |
Can selecting it select its sub-rows? |
getCanMultiSelect |
Config row -> Row row -> Bool |
Can it join a multi-row selection? |
canSelectRange |
Config row -> State -> RowModel row -> String -> Row row -> Bool |
Would a range from this anchor be a range, rather than a plain toggle? |
selectedRowIds |
State -> List String |
The selected ids. |
selectedRowModel |
State -> RowModel row -> RowModel row |
The model with only the selected rows kept. |
SubRowSelection is an abstract type with one value per case:
Table.noSubRowsSelected, Table.someSubRowsSelected, and
Table.allSubRowsSelected.
subTreeLabel : Table.State -> Table.Row Person -> String
subTreeLabel state row =
let
selection : Table.SubRowSelection
selection =
Table.subRowSelection config state row
in
if selection == Table.allSubRowsSelected then
"all"
else if selection == Table.someSubRowsSelected then
"some"
else
"none"
Reading the selected rows
TanStack has three selected row models: getSelectedRowModel,
getFilteredSelectedRowModel, and getGroupedSelectedRowModel. Here that is
one function applied to whichever model you care about, since you already
hold each stage of the pipeline.
selectedPeople : Table.State -> List Person
selectedPeople state =
Table.selectedRowModel state (prePaginated state)
|> .rows
|> List.map Table.rowOriginal
Selected descendants of an unselected parent stay in flatRows and
rowsById but not in rows, matching TanStack.
Render row selection UI
Nothing about the markup is decided for you. A header "select all" checkbox
reads the two table-wide queries, and the indeterminate look is a DOM
property, so it is set with Html.Attributes.property.
viewSelectAllHeader : Model -> Html Msg
viewSelectAllHeader model =
let
allSelected : Bool
allSelected =
Table.getIsAllRowsSelected config model.state (prePaginated model.state)
in
th []
[ input
[ type_ "checkbox"
, checked allSelected
, indeterminate (not allSelected && Table.getIsSomeRowsSelected model.state)
, onCheck ClickedSelectAll
]
[]
]
indeterminate : Bool -> Html.Attribute msg
indeterminate value =
property "indeterminate" (Encode.bool value)
The per-row checkbox is disabled when the row cannot be selected, checked when the row or its whole sub-tree is selected, and indeterminate when only part of its sub-tree is.
viewRowCheckbox : Table.State -> Table.Row Person -> Html Msg
viewRowCheckbox state row =
td []
[ input
[ type_ "checkbox"
, checked (rowIsChecked state row)
, disabled (not (Table.getCanSelect config row))
, indeterminate (Table.getIsSomeSelected config state row)
, on "click" (rowClickDecoder row)
]
[]
]
rowIsChecked : Table.State -> Table.Row Person -> Bool
rowIsChecked state row =
Table.getIsRowSelected state row
|| (Table.getCanSelectSubRows config row
&& Table.getIsAllSubRowsSelected config state row
)
The Shift modifier and the checkbox's resulting value come out of the same click event.
rowClickDecoder : Table.Row Person -> Decode.Decoder Msg
rowClickDecoder row =
Decode.map2
(\shift value -> ClickedRow { row = row, value = value, shift = shift })
(Decode.field "shiftKey" Decode.bool)
(Decode.at [ "target", "checked" ] Decode.bool)
The getCanSelectSubRows and getIsAllSubRowsSelected clauses only matter
for tables with sub-rows. With flat data, getIsRowSelected on its own is
enough.
Not ported
- Toggle handlers.
row.getToggleSelectedHandler(),table.getToggleAllRowsSelectedHandler(), and their siblings are DOM event plumbing. Their state halves are the transitions above; you write theHtml.Eventsattribute. enableRowRangeSelectionandisRowRangeSelectionEvent. Range selection is opt-in at the call site here: you callselectRangewhen your own event says to, so there is no flag to switch off and no event predicate to replace.onRowSelectionChangeandatoms. Every transition returns a newState; storing it is yourupdatefunction's job.autoResetAll. Nothing resets itself when the data changes. CallresetRowSelectionyourself if that is what you want.
Example
Row Selection, ported from TanStack's Row Selection example.