Handling Single, Double, Triple, Quadruple Clicks in Elm
Elm has onClick
and onDoubleClick
event handlers, but what if you need to
handle triple, quadruple or any number of clicks?
We’re going to build a simple program that displays multiple boxes containing multiple rows containing multiple items. We want different behaviors for different numbers of clicks.
- one click: select one item
- two clicks: select all items in a row
- three clicks: select all items in a box
- four clicks: select all items
This tutorial was written for Elm 0.19.
Initial Setup
Create a file called Main.elm
and insert the following lines.
module Main exposing (..)
import Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Json.Decode exposing (..)
main : Program () Model Msg
main =
Browser.sandbox
{ init = init
, update = update
, view = view
}
Our model will only contain our current selection. We’ll use a union type to define it.
type Selection
= All
| Box Int
| Row Int Int
| Item Int Int Int
| Clear
type alias Model =
Selection
init : Model
init =
Clear
The first Int
of Box
, Row
and Item
defines the box, the second Int
of
Row
and Item
defines the row, and the last Int
of Item
defines the item.
We should probably use type aliases to make this clearer:
We’re going to define a Select
message that takes a Selection
and add an
update function to update our model.
type Msg
= Select Selection
update : Msg -> Model -> Model
update msg model =
case msg of
Select selection ->
selection
To complete our sandbox program, we also need a view function.
view : Model -> Html Msg
view model =
div
[ classList
[ ( "main", True )
, ( "selected", model == All )
]
]
(List.map
(box model)
(List.range 1 3)
)
box : Selection -> Int -> Html Msg
box selection boxId =
div
[ classList
[ ( "box", True )
, ( "selected", selection == Box boxId )
]
]
(List.map
(row selection boxId)
(List.range 1 3)
)
row : Selection -> Int -> Int -> Html Msg
row selection boxId rowId =
div
[ classList
[ ( "row", True )
, ( "selected", selection == Row boxId rowId )
]
]
(List.map
(item selection boxId rowId)
(List.range 1 3)
)
item : Selection -> Int -> Int -> Int -> Html Msg
item selection boxId rowId itemId =
div
[ classList
[ ( "item", True )
, ( "selected", selection == Item boxId rowId itemId )
]
]
[]
This will render two boxes with three rows each that in turn contain three items
each. Boxes, rows and items are identified by integers. We are using List.map
and List.range
to avoid typing it all out.
To add the classes, we’re using Html.Attributes.classList
, which takes a list
of tuples with the first element being the class name and the second element
being a bool value.
We always want to give boxes, rows and items the class box
, row
and item
respectively, so we just pass True as the second element. But we only want to
add the class selected
if the box, row or item is actually selected, so we
check if the current selection matches the current element.
This completes the static version of our program. To compile, run:
elm make Main.elm --output=main.js
HTML
Create the file index.html
with the following content.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Handling Triple, Quadruple (Or More) Clicks In Elm</title>
<style>
html,
body,
#elm,
.main {
height: 100%;
margin: 0;
}
.main {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
display: flex;
justify-content: space-between;
align-items: stretch;
}
.box {
flex: 0 1 30%;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: stretch;
}
.row {
flex: 0 1 30%;
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: stretch;
}
.row div {
flex: 0 1 30%;
background: #00caf5;
}
.selected .item,
.item.selected {
background: #002827;
}
</style>
</head>
<body>
<div id="elm"></div>
<script src="main.js" type="text/javascript"></script>
<script type="text/javascript">
var app = Elm.Main.init({
node: document.getElementById("elm")
});
</script>
</body>
</html>
This will embed our Elm app and add some styling. If you open the file in a browser, you should see three columns with nine bland, blue boxes each.
Click Handling
Since Elm doesn’t have a function to handle multiple clicks, we’ll need to write
our own function. We’re going to use the Html.Events.on
function for this.
Let’s add it to our item attributes.
item : Selection -> Int -> Int -> Int -> Html Msg
item selection boxId rowId itemId =
div
[ on "click" (handleClick boxId rowId itemId)
, classList
[ ( "item", True )
, ( "selected", selection == Item boxId rowId itemId )
]
]
[]
on
takes the event name as the first argument (in our case click
) and a
Decoder msg
as the second argument. The click
event has a whole bunch of
properties, and we need
the JSON decoder to access them. In our application, we’re interested in the
vaguely named “detail” property, which contains the number of clicks that
happened within a short time frame.
Our handleClick
function will also need the current box, row and item ID to
make a new selection.
handleClick : Int -> Int -> Int -> Decoder Msg
handleClick boxId rowId itemId =
maybe (field "detail" int)
|> andThen (clickMsg boxId rowId itemId)
Just in case the browser doesn’t support the detail
property, we’re decoding
the detail
field as a Maybe Int
. This Maybe Int
will then be passed to
clickMsg
, which will give us a Msg
depending on the number of clicks.
clickMsg : Int -> Int -> Int -> Maybe Int -> Msg
clickMsg boxId rowId itemId s =
case s of
Just 1 ->
Select (Item boxId rowId itemId)
Just 2 ->
Select (Row boxId rowId)
Just 3 ->
Select (Box boxId)
Just _ ->
Select All
Nothing ->
Select (Item boxId rowId itemId)
In case the browser doesn’t pass a proper detail
property, we want to select a
single item only, so we still have some basic functionality. Because case
needs branches for all possibilities, we need to define a Just _
branch to
handle more than three clicks in a row. We want to select all items in that
case, so that all items are being selected even if the user accidentally clicks
too often.
Let’s compile this again:
elm make Main.elm --output=main.js
Our app should now behave as per spec, but we can clean this up a little bit.
Cleaning Up
We’re going to replace Json.Decode.andThen
with Json.Decode.map
first. This
will save us the call to Json.Decode.succeed
and a pair of brackets.
handleClick : Int -> Int -> Int -> Decoder Msg
handleClick boxId rowId itemId =
maybe (field "detail" int)
|> Json.Decode.map (clickMsg boxId rowId itemId)
clickMsg : Int -> Int -> Int -> Maybe Int -> Msg
clickMsg box row item s =
case s of
Just 1 ->
Select (Item box row item)
Just 2 ->
Select (Row box row)
Just 3 ->
Select (Box box)
Just _ ->
Select All
Nothing ->
Select (Item box row item)
That’s better, but it’s kind of useless to pass box
, row
and item
to our
handleClick
function just to pass it along without doing anything with it.
This also makes reusing handleClick
harder. Let’s rename the function to
onMultipleClick
and move the call to Html.Events.on
from the view to this
function.
item : Selection -> Int -> Int -> Int -> Html Msg
item selection boxId rowId itemId =
div
[ onMultiClick (clickMsg boxId rowId itemId)
, classList
[ ( "item", True )
, ( "selected", selection == Item boxId rowId itemId )
]
]
[]
onMultiClick : (Maybe Int -> msg) -> Attribute msg
onMultiClick intToMsg =
maybe (field "detail" int)
|> Json.Decode.map intToMsg
|> on "click"
Now we only need to pass a function with the signature (Maybe Int -> msg)
to
our new onMultiClick
function. That way, it is not bound to this specific use
case and can be reused anywhere. Also note the use of msg
instead of Msg
in
the type signature. In doing so, we can factor the function out and use it in
any package.
The result
You can find a working demo at woylie.de/elm-triple-click-example and the complete code example on Github.