Working with the DOM
Posted on September 27, 2017Previously we looked at Dynamic
s, which combine the fundamental Event
and Behavior
types to allow us to do some things more efficiently. Now we’ll put them to use and actually create some DOM elements.
We’ll be building up pieces of a todo-list application along the way. It is probably becoming a cliche by now, but it is familiar to a lot of people and will give me something concrete to use while demonstrating some of the cooler things that reflex
provides as the series progresses.
The DomBuilder
monad
When we want to create some DOM elements we use the reflex-dom
library.
The main thing that it provides is an FRP aware builder monad for the DOM. The DomBuilder
typeclass indicates that we are building up a DOM tree.
We can lay out elements using el
:
and can add text nodes with text
:
or dynText
:
As an aside: the PostBuild
typeclass gives us access to an Event
which fires when the element is added to the DOM tree, and is handy for setting up initial values and so on.
With the above pieces in hand, we can put together a simple div
with some text in it using:
or:
That’s all well and good, but it’s very static.
Buttons
The simplest thing we can add to make things more interactive is a button.
We can add a button to our DOM tree with button
:
We can use this to create a very boring todo item:
todoItem :: MonadWidget t m
=> Dynamic t Text
-> m (Event t ())
todoItem dText =
el "div" $ do
el "div" $
dynText dText
button "Remove"
This is following some common reflex
advice about components: start with Dynamic
s as inputs and Event
s as outputs. We’ll come back to this later, and will see when to break those rules, but it’s a very useful place to start.
We use Dynamic
s as much as we can to update the DOM. The Behavior
aspect of the Dynamic
keeps track of some state, and the Event
aspect of the Dynamic
fires when that state updates. For the smaller changes to the DOM, we can attach a handler for that Event
at a particular DOM node to do what needs doing when the Event
fires. If you set things up correctly, this can lead to the same effect as working with a virtual DOM but without needing to do the diffing or patching. The fact that we can pass these Dynamic
s around as first class values is the cherry on top.
It also introduces MonadWidget
, which is a constraint synonym for a long list of typeclasses that are often used when created components that will be translated to parts of a DOM tree, including the DomBuilder t m
constraint.
If we want to see something happen when that Event
is fired, we can use it to modify the text we are displaying.
This gives us a marginally less boring todo item:
example :: MonadWidget t m
=> Dynamic t Text
-> m ()
example dText = el "div" $ mdo
eRemove <- todoItem $ dText <> dLabel
dLabel <- holdDyn "" $
" (Removed)" <$ eRemove
pure ()
We’re using the RecursiveDo
language extension and the mdo
keyword in the above code, which was discussed in the previous post. We use RecursiveDo
with reflex
code when we have cycles in our FRP network.
We use it more often with reflex-dom
. This is because we are working in a builder monad, and so the order in which widgets appear in the code is the order in which the widgets are laid out on the page. Sometimes a widget will need access to an Event
or Dynamic
produced by a widget that is laid out further down on the page, and RecursiveDo
lets us make the forward references that enable that.
It can be a little mind-bending the first time you come across, but it doesn’t take long until it starts to feel natural.
Various functions for creating elements
There are variants of the el
function which allow for more customization.
Adding classes
We can add a class to an element:
or we can add class to an element that will change over time:
This lets us make our todo item a little prettier by adding a class to the item itself, and by using color to indicate when an item has been removed:
todoItem :: MonadWidget t m
=> Dynamic t Text
-> m (Event t ())
todoItem dText = elClass "div" "todo-item" $ mdo
elDynClass "div" dRemoveClass $
dynText dText
eRemove <- button "Remove"
dRemoveClass <- holdDyn "" $
"removed" <$ eRemove
pure eRemove
Adding attributes
There are also functions which allow us to specify arbitrary attributes:
elAttr :: DomBuilder t m
=> Text
-> Map Text Text
-> m a
-> m a
elDynAttr :: (DomBuilder t m, PostBuild t m)
=> Text
-> Dynamic t (Map Text Text)
-> m a
-> m a
We could use this to hide the text when an item is removed:
todoItem :: MonadWidget t m
=> Dynamic t Text
-> m (Event t ())
todoItem dText = elClass "div" "todo-item" $ mdo
elDynAttr "div" dAttr $
dynText dText
eRemove <- button "Remove"
dAttr <- foldDyn (<>) mempty $
"hidden" =: "" <$ eRemove
pure eRemove
The code above contains a use of the helper =:
, which is the Map.singleton
function in operator form. It pops up in reflex
code, so it’s good to know about.
Handling new events
All of the above functions for producing DOM elements have variants that expose the underlying element.
They all have a prime at the end of their names and return a pair. For instance:
We can use these along with domEvent
:
class HasDomEvent t target eventName where
type DomEventType target eventName :: *
domEvent :: EventName eventName -> target -> Event t (DomEventType target eventName)
to create new reflex
Event
s from various DOM events. It looks hideous, but it is fairly easy to use.
If we wanted a clickable link we could do something like:
To extend our previous example, we could clear the “removed” state of our item when the text is double clicked:
todoItem :: MonadWidget t m
=> Dynamic t Text
-> m (Event t ())
todoItem dText = elClass "div" "todo-item" $ mdo
(e, _) <- elDynClass' "div" dClass $
dynText dText
let
eDoubleClick = domEvent Dblclick e
eRemove <- button "Remove"
dClass <- holdDyn "" . leftmost $ [
"" <$ eDoubleClick
, "removed" <$ eRemove
]
pure eRemove
We can use this to add support for any of the usual DOM events to our widgets.
Checkboxes
There are more complicated inputs than buttons.
The simplest step up is a checkbox. This gives us a little bit of insight into the design of components in reflex-dom
.
We have a data type that contains the information we need to create the component:
data CheckboxConfig t =
CheckboxConfig {
_checkboxConfig_setValue :: Event t Bool
, _checkboxConfig_attributes :: Dynamic t (Map Text Text)
}
which typically has a Default
instance:
and we have a data type that contains the information we might want from the component:
Both of these data types have lenses available. We’re only making basic usage of them for the time being, but they can be very useful.
Our use of lenses will be limited to setting up our configuration data types:
cb <- checkbox False $
def & checkboxConfig_setValue .~ eUpdateMe
& checkboxConfig_attributes .~ pure ("disabled" =: "false")
and to accessing fields of the resulting data type:
The two data types are linked together with a function that lays the checkbox out in the DOM tree - in this case it also has an argument for the initial state of the checkbox:
We’re now ready to add a checkbox to our todo item:
todoItem :: MonadWidget t m
=> Dynamic t Text
-> m (Event t Bool, Event t ())
todoItem dText =
elClass "div" "todo-item" $ mdo
cb <- checkbox False def
let
eComplete = cb ^. checkbox_change
elDynClass "div" dRemoveClass $
dynText dText
eRemove <- button "Remove"
dRemoveClass <- holdDyn "" $
"removed" <$ eRemove
pure (eComplete, eRemove)
although we’d rather have some visual indicator that the checkbox is working correctly:
todoItem :: MonadWidget t m
=> Dynamic t Text
-> m (Event t Bool, Event t ())
todoItem dText =
elClass "div" "todo-item" $ mdo
cb <- checkbox False def
let
eComplete = cb ^. checkbox_change
dComplete = cb ^. checkbox_value
mkCompleteClass False = ""
mkCompleteClass True = "completed "
dCompleteClass = mkCompleteClass <$> dComplete
elDynClass "div" (dCompleteClass <> dRemoveClass) $
dynText dText
eRemove <- button "Remove"
dRemoveClass <- holdDyn "" $
"removed " <$ eRemove
pure (eComplete, eRemove)
We’ll also use checkboxes for some other todo-list related functionality.
We can make a component we can use for clearing completed items, and have it only be visible when at least one todo item is completed:
clearComplete :: MonadWidget t m
=> Dynamic t Bool
-> m (Event t ())
clearComplete dAnyComplete =
let
mkClass False = "hide"
mkClass True = ""
dClass = mkClass <$> dAnyComplete
in
elDynClass "div" dClass $
button "Clear complete"
We can also make a component that has a checkbox which causes all todo items to be marked as complete or incomplete, depending on the state of the checkbox. We’ll also make this binding bidirectional - if all of the items are marked as complete, the checkbox will become checked, and that ceases to be the case then the checkbox will become unchecked.
markAllComplete :: MonadWidget t m
=> Dynamic t Bool
-> m (Event t Bool)
markAllComplete dAllComplete = do
cb <- checkbox False $
def & checkboxConfig_setValue .~ updated dAllComplete
text "Mark all as complete"
pure $ cb ^. checkbox_change
Text inputs
We’re going to skip ahead, from the simple checkbox to the much more complex text input.
There are other inputs in reflex-dom
, but once you can handle the checkbox and the text input you should be ready to use the other inputs without too much help.
The text input has a larger configuration data type:
data TextInputConfig t =
TextInputConfig {
_textInputConfig_inputType :: Text
, _textInputConfig_initialValue :: Text
, _textInputConfig_setValue :: Event t Text
, _textInputConfig_attributes :: Dynamic t (Map Text Text)
}
the usual Default
instance:
and a much larger data type for the information we might want from the component:
data TextInput t =
TextInput {
_textInput_value :: Dynamic t Text
, _textInput_input :: Event t Text
, _textInput_keypress :: Event t Word
, _textInput_keydown :: Event t Word
, _textInput_keyup :: Event t Word
, _textInput_hasFocus :: Dynamic t Bool
, _textInput_builderElement :: InputElement EventResult GhcjsDomSpace t
}
The initial value is part of the configuration data type now, so we can add these to the page with a slightly simpler function:
We’re going to use this to make an input we can use to add items to our todo list. We want to fire an Event
with the Text
of the item that we want to add when there is text in the input and the user presses the enter key. We also want to clear the input when that happens.
Let’s put this together one piece at a time.
We’ll start by putting a text input on the page:
and then we’ll set some placeholder text for it:
addItem :: MonadWidget t m
=> m (Event t Text)
addItem = mdo
ti <- textInput $
def & textInputConfig_attributes .~
pure ("placeholder" =: "What shall we do today?")
pure _
We can get hold of the current value of the text input:
addItem :: MonadWidget t m
=> m (Event t Text)
addItem = mdo
ti <- textInput $
def & textInputConfig_attributes .~
pure ("placeholder" =: "What shall we do today?")
let
bValue = current (ti ^. textInput_value)
pure _
and by jumping through some type conversion hoops we can create an Event
that fires when the user presses enter:
addItem :: MonadWidget t m
=> m (Event t Text)
addItem = mdo
ti <- textInput $
def & textInputConfig_attributes .~
pure ("placeholder" =: "What shall we do today?")
let
bValue = current (ti ^. textInput_value)
eKeypress = ti ^. textInput_keypress
isKey k = (== k) . keyCodeLookup . fromIntegral
eEnter = ffilter (isKey Enter) eKeypress
pure _
We can use that to sample the value of the text input at the time that the user pressed enter:
addItem :: MonadWidget t m
=> m (Event t Text)
addItem = mdo
ti <- textInput $
def & textInputConfig_attributes .~
pure ("placeholder" =: "What shall we do today?")
let
bValue = current (ti ^. textInput_value)
eKeypress = ti ^. textInput_keypress
isKey k = (== k) . keyCodeLookup . fromIntegral
eEnter = ffilter (isKey Enter) eKeypress
eAtEnter = bValue <@ eEnter
pure _
and we can filter out the times at which that happened while the text input was empty:
addItem :: MonadWidget t m
=> m (Event t Text)
addItem = mdo
ti <- textInput $
def & textInputConfig_attributes .~
pure ("placeholder" =: "What shall we do today?")
let
bValue = current (ti ^. textInput_value)
eKeypress = ti ^. textInput_keypress
isKey k = (== k) . keyCodeLookup . fromIntegral
eEnter = ffilter (isKey Enter) eKeypress
eAtEnter = bValue <@ eEnter
eDone = ffilter (not . Text.null) eAtEnter
pure _
That gives us the Event
that we wanted to return:
addItem :: MonadWidget t m
=> m (Event t Text)
addItem = mdo
ti <- textInput $
def & textInputConfig_attributes .~
pure ("placeholder" =: "What shall we do today?")
let
bValue = current (ti ^. textInput_value)
eKeypress = ti ^. textInput_keypress
isKey k = (== k) . keyCodeLookup . fromIntegral
eEnter = ffilter (isKey Enter) eKeypress
eAtEnter = bValue <@ eEnter
eDone = ffilter (not . Text.null) eAtEnter
pure eDone
and thanks to the wonders of RecursiveDo
, we can use that Event
to clear the text input:
addItem :: MonadWidget t m
=> m (Event t Text)
addItem = mdo
ti <- textInput $
def & textInputConfig_attributes .~
pure ("placeholder" =: "What shall we do today?")
& textInputConfig_setValue .~
("" <$ eDone)
let
bValue = current (ti ^. textInput_value)
eKeypress = ti ^. textInput_keypress
isKey k = (== k) . keyCodeLookup . fromIntegral
eEnter = ffilter (isKey Enter) eKeypress
eAtEnter = bValue <@ eEnter
eDone = ffilter (not . Text.null) eAtEnter
pure eDone
Playing along at home
If you want to test out your understanding of how you can use reflex-dom
to work with the DOM, there are exercises here. These exercises build up incrementally as the series progresses, so it would probably best to start the exercises beginning at the start of the series.
Next up
There is much more to say about working with the DOM, but this should have given you an understanding of the basics. The next few posts will cover some additional tools and techniques while using and expanding on the functionality presented here.
In the next post we’ll look at some tools reflex
provides for making modifications to the graph in response to Event
s.
We’re preparing educational materials about the reflex
library, and using it to see what exciting things we can do with FRP.
> Dave Laing
Dave is a programmer working at the Queensland Functional Programming Lab.