Cell Selection
Cell selection tracks spreadsheet-style rectangular selections: a cell, a
block dragged across, a Shift-extended range, and further rectangles added or
subtracted with Ctrl or Cmd. It is the port of TanStack Table's Cell
Selection guide and its cellSelectionFeature.
Selections are stored as ranges of ids, not as a set of cells, so a selection of ten thousand cells is a few records. Everything else, including which cells are actually inside the selection, is derived on read.
When a column merges cells, see Cell Spanning: a selection rectangle grows to enclose any merged cell it touches, so a merge is always entirely selected or entirely unselected.
config : Table.Config Person
config =
Table.config columns
|> Table.withGetRowId (\person _ _ -> person.id)
|> Table.withCellRangeSelection True
|> Table.withMultiCellRangeSelection True
Selection is keyed by row id and column id, so give rows stable ids with
Table.withGetRowId for the same reason as in
Row Selection.
State
The slice is an ordered list of range operations.
-- in Table.State
, cellSelection : List CellSelectionRange
type alias CellSelectionRange =
{ anchorColumnId : String
, anchorRowId : String
, focusColumnId : String
, focusRowId : String
, operation : CellSelectionOperation
}
-- in Table.initialState
, cellSelection = []
The anchor corner is where the selection started and stays put; the focus corner is the one that moves while dragging or Shift-extending. Keeping both corners, rather than a normalized rectangle, is what makes "extend from where I started" possible.
Ranges apply in order. Table.includeCells adds its rectangle,
Table.excludeCells subtracts it, so "everything except these two cells" is
three small records rather than a list of every selected cell. TanStack's
operation field is optional and defaults to an inclusion; here it is
required, and cellRange builds an
inclusion.
Because ranges store ids, sorting, filtering, and column reordering keep the corners pinned and recompute what falls between them. Hiding a column that a corner sits on makes the range inert: nothing renders as selected, the range stays in state, and it comes back when the column is shown again.
Config options
| Option | Type | Default | Description |
|---|---|---|---|
enableCellSelection |
Bool |
True |
Can cells be selected at all. |
cellSelectionFilter |
Maybe (Cell -> Bool) |
Nothing |
Decide per cell. When set, it replaces the boolean. |
enableCellRangeSelection |
Bool |
True |
Can a selection be extended into a range, which is what Shift-click and drag do. |
enableMultiCellRangeSelection |
Bool |
True |
Can further rectangles be added or subtracted, which is what Ctrl-click and Cmd-click do. |
TanStack's enableCellSelection takes boolean | ((cell) => boolean). The
two are separate fields here, and the distinction is real:
withCellSelection False makes selectAllCells a no-op, while a predicate
that always answers False does not.
| Builder | Sets |
|---|---|
withCellSelection |
enableCellSelection |
withCellSelectionWhen |
cellSelectionFilter |
withCellRangeSelection |
enableCellRangeSelection |
withMultiCellRangeSelection |
enableMultiCellRangeSelection |
salaryColumnOnly : Table.Config Person
salaryColumnOnly =
Table.withCellSelectionWhen (\cell -> cell.columnId == "salary") config
Column options
| Option | Type | Default | Description |
|---|---|---|---|
enableCellSelection |
Bool |
True |
Can this column's cells be selected. False wins over the table option. |
Set it with
withEnableCellSelection.
This is the usual way to keep a checkbox or actions column out of a
selection.
columns : List (Table.Column Person)
columns =
[ Table.column "firstName" (.firstName >> Value.String)
|> Table.withHeader "First name"
, Table.column "salary" (.salary >> Value.Number)
|> Table.withHeader "Salary"
, Table.display "actions"
|> Table.withHeader "Actions"
|> Table.withEnableCellSelection False
]
A cell that cannot be selected is skipped even when a rectangle is drawn
straight through it, and moveCellSelection steps over its column instead of
landing on it.
Transitions
| Transition | Type | What it does |
|---|---|---|
selectCell |
Config row -> Cell -> State -> State |
Start a selection at one cell, replacing what was selected. |
extendCellSelectionTo |
Config row -> Cell -> State -> State |
Move the active range's focus corner to this cell, keeping its anchor. |
toggleCellSelection |
Config row -> SelectionRows row -> Cell -> State -> State |
Add a rectangle at this cell, or subtract one when the cell is already selected. |
moveCellSelection |
Config row -> SelectionRows row -> CellDirection -> State -> State |
Collapse the selection to a single cell one step away. |
extendCellSelection |
Config row -> SelectionRows row -> CellDirection -> State -> State |
Move the focus corner one step, keeping the anchor. |
setFocusedCell |
String -> String -> State -> State |
Collapse the selection to one named cell. |
selectAllCells |
Config row -> SelectionRows row -> State -> State |
Select every selectable cell as one range. |
selectCellRange |
CellSelectionRange -> State -> State |
Select a rectangle, replacing the selection. |
selectCellRangeWith |
CellSelectionMode -> CellSelectionRange -> State -> State |
The same with replace, include, or exclude semantics. |
setCellSelection |
List CellSelectionRange -> State -> State |
Replace the whole slice. |
clearCellSelection |
State -> State |
Drop every range. |
CellSelectionMode is abstract, with Table.replaceSelection,
Table.includeSelection, and Table.excludeSelection. CellDirection is
abstract too: Table.cellUp, Table.cellDown, Table.cellLeft, and
Table.cellRight.
selectAllOfOneColumn : Table.State -> Table.State
selectAllOfOneColumn state =
Table.selectCellRangeWith Table.includeSelection
(Table.cellRange "1" "salary" "6" "salary")
state
The two row models
Most transitions and every query take a SelectionRows, which carries the
model before the page slice and the model of the current page.
type alias SelectionRows row =
{ prePaginated : RowModel row
, current : RowModel row
}
prePaginated fixes the display-order indexes a range resolves against, so a
range spans pages and lights up correctly on whichever page you are viewing.
current is the page you render, which bounds keyboard navigation and cell
spanning. Without pagination the two are the same model.
selectionRows : Table.State -> Table.SelectionRows Person
selectionRows state =
let
beforePaging : Table.RowModel Person
beforePaging =
Table.coreRowModelFromList config state people
|> Table.filteredRowModel config state
|> Table.sortedRowModel config state
|> Table.expandedRowModel config state
in
{ prePaginated = beforePaging
, current = Table.paginatedRowModel config state beforePaging
}
Mouse interactions
TanStack has cell.getSelectionStartHandler() and
cell.getSelectionExtendHandler(), which attach their own document-level
mouseup listener and keep a drag flag on the table instance. None of that
exists here. You map your own onMouseDown, onMouseEnter, and onKeyDown
to the transitions above, and you own the "is the button still down" flag.
init : Model
init =
{ state = Table.initialState
, dragging = False
}
A mousedown picks one of three transitions from the modifier keys: plain is
selectCell, Shift is extendCellSelectionTo, and Ctrl or Cmd is
toggleCellSelection.
mouseDown : Modifiers -> Table.Cell -> Table.State -> Table.State
mouseDown modifiers cell state =
if modifiers.shift then
Table.extendCellSelectionTo config cell state
else if modifiers.multi then
Table.toggleCellSelection config (selectionRows state) cell state
else
Table.selectCell config cell state
A mouseenter extends the active range, but only while a drag is in
progress; that guard is the flag in your model, since extendCellSelectionTo
itself does not know a drag from a click.
update : Msg -> Model -> Model
update msg model =
case msg of
MouseDownOnCell modifiers cell ->
{ model | dragging = True, state = mouseDown modifiers cell model.state }
MouseEnteredCell cell ->
if model.dragging then
{ model | state = Table.extendCellSelectionTo config cell model.state }
else
model
ReleasedMouse ->
{ model | dragging = False }
PressedArrow modifiers direction ->
{ model | state = arrowKey modifiers direction model.state }
PressedSelectAll ->
{ model | state = Table.selectAllCells config (selectionRows model.state) model.state }
PressedEscape ->
{ model | state = Table.clearCellSelection model.state }
Shift and the platform modifier come off the event.
modifiersDecoder : Decode.Decoder Modifiers
modifiersDecoder =
Decode.map2 Modifiers
(Decode.field "shiftKey" Decode.bool)
(Decode.map2 (||)
(Decode.field "ctrlKey" Decode.bool)
(Decode.field "metaKey" Decode.bool)
)
withCellRangeSelection False makes extendCellSelectionTo select the cell
instead of extending, and withMultiCellRangeSelection False does the same
for toggleCellSelection, so the handlers above stay as they are when either
is switched off.
Keyboard navigation
Arrow keys move, Shift and an arrow extend, and Escape clears. Nothing here
listens for keys on your behalf; decode them and send a Msg.
arrowKey : Modifiers -> Table.CellDirection -> Table.State -> Table.State
arrowKey modifiers direction state =
if modifiers.shift then
Table.extendCellSelection config (selectionRows state) direction state
else
Table.moveCellSelection config (selectionRows state) direction state
keyMsg : ( String, Modifiers ) -> Decode.Decoder Msg
keyMsg ( key, modifiers ) =
case key of
"ArrowUp" ->
Decode.succeed (PressedArrow modifiers Table.cellUp)
"ArrowDown" ->
Decode.succeed (PressedArrow modifiers Table.cellDown)
"ArrowLeft" ->
Decode.succeed (PressedArrow modifiers Table.cellLeft)
"ArrowRight" ->
Decode.succeed (PressedArrow modifiers Table.cellRight)
"Escape" ->
Decode.succeed PressedEscape
_ ->
Decode.fail "not a navigation key"
Bind the decoder to the table element rather than the document, or arrow keys and Escape will take over inputs elsewhere on the page.
Queries
| Query | Type | Answers |
|---|---|---|
cellCanSelect |
Config row -> Cell -> Bool |
Can this cell be selected? |
cellIsSelected |
Config row -> State -> SelectionRows row -> Cell -> Bool |
Is it inside the final positive selection? |
cellIsFocused |
State -> Cell -> Bool |
Is it the active cell? |
cellTabIndex |
State -> Cell -> Int |
0 for the focused cell, -1 otherwise. |
cellSelectionEdges |
Config row -> State -> SelectionRows row -> Cell -> CellSelectionEdges |
Which of its sides sit on the selection boundary. |
focusedCell |
Config row -> State -> SelectionRows row -> Maybe Cell |
The active cell itself. |
cellSelectionBounds |
Config row -> State -> SelectionRows row -> List CellSelectionBounds |
The selection as disjoint index rectangles. |
cellSelectionMergeBounds |
Config row -> State -> SelectionRows row -> List CellSelectionBounds |
The merged-cell rectangles in the same index space. |
cellSelectionColumnIndexes |
Config row -> State -> Dict String Int |
The render-order index of every visible column id. |
selectedCellIds |
Config row -> State -> SelectionRows row -> List String |
The ids of the selected cells, in row-major order. |
selectedCellCount |
Config row -> State -> SelectionRows row -> Int |
How many cells are selected; a merge counts once. |
selectedCellRangesData |
Config row -> State -> SelectionRows row -> List (List (List Value)) |
Each positive region's values as a row-major grid. |
cellSelectionRowIds |
Config row -> State -> SelectionRows row -> List String |
The rows the selection touches. |
cellSelectionColumnIds |
Config row -> State -> SelectionRows row -> List String |
The columns it touches. |
CellSelectionBounds is a rectangle of inclusive display-order indexes:
rows are positions in the pre-pagination display order, columns are positions
in the visible leaf columns in render order.
type alias CellSelectionBounds =
{ minRowIndex : Int
, maxRowIndex : Int
, minColumnIndex : Int
, maxColumnIndex : Int
}
Nothing is memoized, so a query costs what it computes. Call the cheap ones
per cell and the enumerating ones (selectedCellIds,
selectedCellRangesData) once, when you actually need the list.
selectionSummary : Table.State -> String
selectionSummary state =
let
source : Table.SelectionRows Person
source =
selectionRows state
in
String.fromInt (Table.selectedCellCount config state source)
++ " cells across "
++ String.fromInt (List.length (Table.cellSelectionRowIds config state source))
++ " rows"
Render cell selection UI
cellSelectionEdges gives { top, right, bottom, left }, where a side is
True when the neighbour in that direction is not itself selected. That is
what draws one continuous outline around a selection, including around a
union of separate rectangles, without any cell inspecting its neighbours.
cellClass : Table.State -> Table.Cell -> String
cellClass state cell =
let
source : Table.SelectionRows Person
source =
selectionRows state
focus : List String
focus =
if Table.cellIsFocused state cell then
[ "cell-focused" ]
else
[]
in
if Table.cellIsSelected config state source cell then
String.join " "
(("cell" :: "cell-selected" :: focus)
++ edgeClasses (Table.cellSelectionEdges config state source cell)
)
else
String.join " " ("cell" :: focus)
edgeClasses : Table.CellSelectionEdges -> List String
edgeClasses edges =
List.filterMap identity
[ edgeClass "edge-top" edges.top
, edgeClass "edge-right" edges.right
, edgeClass "edge-bottom" edges.bottom
, edgeClass "edge-left" edges.left
]
The cell element carries the class, the roving tab index, and the two mouse
handlers. Table.Cell is a plain record, so cell.value is the value to
render and cell.columnId and cell.rowId are its coordinates.
viewCell : Table.State -> Table.Cell -> Html Msg
viewCell state cell =
td
[ class (cellClass state cell)
, tabindex (Table.cellTabIndex state cell)
, on "mousedown" (Decode.map (\m -> MouseDownOnCell m cell) modifiersDecoder)
, on "mouseenter" (Decode.succeed (MouseEnteredCell cell))
]
[ text (Value.toString cell.value) ]
Draw the outline with box-shadow: inset ... rather than border. On a
border-collapse table a thicker border widens the shared grid line, so rows
change height as cells become selected.
Copying a selection
selectedCellRangesData returns raw values indexed as region, then row, then
column. A region is one of the final disjoint positive rectangles after every
include and exclude is applied. Turning that into clipboard text is your
decision, because the delimiter, the representation of Null, and any
quoting rules are yours.
selectionAsTsv : Table.State -> String
selectionAsTsv state =
Table.selectedCellRangesData config state (selectionRows state)
|> List.map gridToTsv
|> String.join "\n\n"
Elm cannot write to the clipboard on its own; send the string out through a port.
Geometry helpers
The rectangle algebra the feature uses is exposed, for building your own
selection tools on top of CellSelectionBounds.
| Function | Type |
|---|---|
intersectCellSelectionBounds |
CellSelectionBounds -> CellSelectionBounds -> Maybe CellSelectionBounds |
subtractCellSelectionBounds |
CellSelectionBounds -> CellSelectionBounds -> List CellSelectionBounds |
addCellSelectionBounds |
List CellSelectionBounds -> CellSelectionBounds -> List CellSelectionBounds |
mergeAdjacentCellSelectionBounds |
List CellSelectionBounds -> List CellSelectionBounds |
expandCellSelectionBounds |
CellSelectionBounds -> List CellSelectionBounds -> CellSelectionBounds |
applyCellSelectionBoundsOperations |
List ( CellSelectionOperation, CellSelectionBounds ) -> List CellSelectionBounds |
applyCellSelectionBoundsOperations is the whole pipeline in one call: run
ordered includes and excludes and get back disjoint rectangles.
expandCellSelectionBounds is the step that grows a rectangle until it
contains every merged cell it touches.
Not ported
- Handlers and the drag session.
getSelectionStartHandler,getSelectionExtendHandler, the documentmouseuplistener,enableCellSelectionDrag, and the instance's_isSelectingCellsflag have no counterpart. You wire the events and keep the flag. isCellRangeSelectionEventandisMultiCellRangeSelectionEvent. Your own decoder decides which transition a click means, so there is no event predicate to override.- The deprecated
additiveoption.selectCellRangeWithwith aCellSelectionModeis the only form. autoResetCellSelection. Nothing resets when the data changes. If new data can reuse row ids, callclearCellSelectionwhen you replace it.resetCellSelection()restoring an initial slice. There is notable.initialState;clearCellSelectionis TanStack'sresetCellSelection(table, true), andsetCellSelectionrestores a slice you kept.table.Subscribeand the render-performance section. Elm's virtual DOM handles the re-render, and there are no getters hiding a state dependency. If a large table feels slow, compute the row models once perupdateand store them rather than rebuilding them per cell.
Example
Cell Selection, ported from TanStack's Cell Selection example.