Skip to content

Latest commit

 

History

History
513 lines (408 loc) · 15.9 KB

tutorial-08.md

File metadata and controls

513 lines (408 loc) · 15.9 KB

Tutorial 8 - DOM manipulation

In the previous tutorial we introduced the domina.events namespace to make our events management a little bit more clojure-ish than just using CLJS/JS interop. In this tutorial we're going to face the need to programmatically manipulate DOM elements as a result of the occurrence of some DOM events (e.g., mouseover, mouseout, etc.)

Preamble

If you want to start working from the end of the previous tutorial, assuming you've git installed, do as follows:

git clone https://github.com/magomimmo/modern-cljs.git
cd modern-cljs
git checkout se-tutorial-07

Introduction

As we already saw, the domina library has a lot to offer for managing the selection of DOM elements and for handling almost any DOM event. Let's continue by using it to verify how it could help us in managing the manipulation of DOM elements, one of the most important features of any good JS library and/or framework.

To reach this goal, we're going to use the shopping calculator example again by adding both a mouseover and a mouseout event handler to its Calculate button.

The mouseover handler reacts by adding "Click to calculate" to the form itself. The mouseout handler reacts by deleting that text. Yes, I know, the requirement is very simple but, as you will see, pretty representative of a kind of problem you're going to face again and again in your CLJS programming.

Start the IFDE and the bREPL

As usual we like to work in the IFDE/bREPL live environment.

Start the IFDE:

cd /path/to/modern-cljs
boot dev
...
Compiling ClojureScript...
• main.js
Elapsed time: 23.931 sec

Then start the bREPL:

# from a new terminal
cd /path/to/modern-cljs
boot repl -c
...
boot.user=> (start-repl)
...

and finally visit the shopping URL to activate the bREPL.

bREPLing with DOM manipulation

For bREPLing with the Shopping Form we first need to require the needed namespaces:

cljs.user> (require '[modern-cljs.shopping :as shop] :reload
                    '[domina.core :as dom] :reload
                    '[domina.events :as evt] :reload)
nil

DOM manipulation

Take a look at the docstring attached to the append! function (note the ! bang meaning this function mutates the passed arguments):

cljs.user> (doc dom/append!)
-------------------------
domina/append!
([parent-content child-content])
  Given a parent and child contents, appends each of the children to all
  of the parents. If there is more than one node in the parent content,
  clones the children for the additional parents. Returns the parent content.
nil

Here is a simple example of append! usage from domina readme:

;;; from domina readme.md
(append! (xpath "//body") "<div>Hello world!</div>")

It appends a <div> node to the end of the <body> node. It uses xpath to select a single parent (i.e., <body>) and a string to represent a single <div> child to be added to the parent.

I don't know about you, but I don't feel comfortable with xpath, and I only use it when no equivalent CSS selector is available (e.g., ancestor selection) or when the selection is too complex to be managed and/or maintained.

Anyway, domina offers you three options for node selection:

  • xpath from domina.xpath namespace
  • sel from domina.css namespace
  • by-id and by-class from domina.core namespace

Thankfully append! accepts, as a first argument, any domina expression that returns one or more content (i.e., one or more DOM nodes). This means that, for such a simple case, we can safely use the by-id selector to select the parent to be passed to append!.

Let's see how append! works within the bREPL:

cljs.user> (dom/append! (dom/by-id "shoppingForm")
                        "<div class='help'>Click to calculate</div>")
#object[HTMLFormElement [object HTMLFormElement]]

You should now see the Click to calculate text in the Shopping Form.

Note that we used the help class attribute to be able to remove any help element when later we'll implement the listener for managing the mouseout event for the calc button.

Mouseover event

We can now start to add a mouseover handler to the Calculate button by using the same listen! function we already used for triggering the calculate listener.

Go back to your bREPL and enter the following expression:

cljs.user> (evt/listen! (dom/by-id "calc")
                        :mouseover
                        (fn []
                          (dom/append!
                           (dom/by-id "shoppingForm")
                           "<div class='help'>Click to calculate</div>")))
(#object[Object [object Object]])

Here we attached to the mouseover event an anonymous function doing the same thing we tested above.

Go to the form. You'll see a new Click to calculate help message being added to the form each time you enter the button area like in the following figure:

Shopping calculator

So far, so good. We now need to remove the help message each time the mouse pointer exits the button area again.

Mouseout event

Thankfully, the domina.events namespace supports the mouseout event as well.

The domina.core namespace even offers the destroy! function to permanently delete a DOM element and all its children altogether.

Go back to the bREPL and ask for the destroy! docstring:

cljs.user> (doc dom/destroy!)
-------------------------
domina.core/destroy!
([content])
  Removes all the nodes in a content from the DOM. Returns nil.
nil

We also need a way to select the div tag. As you remember we set the value of the CSS class attribute of the added div to help. Again, the domina.core namespace exposes a by-class function to select all the elements which are members of a specified class:

cljs.user> (doc dom/by-class)
-------------------------
domina.core/by-class
([class-name])
  Returns content containing nodes which have the specified CSS class.
nil
cljs.user> (dom/by-class "help")
(#object[HTMLDivElement [object HTMLDivElement]] #object[HTMLDivElement [object HTMLDivElement]] #object[HTMLDivElement [object HTMLDivElement]] #object[HTMLDivElement [object HTMLDivElement]])

If you call the destroy! function on the sequence returned by the by-class function, you'll see all the Click to calculate messages to be deleted:

cljs.user> (dom/destroy! (dom/by-class "help"))
nil

The last experiment we want to do within the bREPL before we start coding in the shopping.cljs file is to attach a listener to the mouseout event for the calc button:

cljs.user> (evt/listen! (dom/by-id "calc")
                        :mouseout (fn []
                                    (dom/destroy! (dom/by-class "help"))))
(#object[Object [object Object]])

Go back to the Shopping Form. As soon as you enter the button area you'll see the message. As soon as you exit the button area the message will disappear.

Edit shopping.cljs

Having familiarized a little bit more with the domina lib, we are now ready to code into the shopping.cljs file what we learned within the bREPL.

Here is the updated content:

(ns modern-cljs.shopping
  (:require [domina.core :refer [append!
                                 by-class
                                 by-id
                                 destroy!
                                 set-value!
                                 value]]
            [domina.events :refer [listen!]]))

(defn calculate []
  (let [quantity (value (by-id "quantity"))
        price (value (by-id "price"))
        tax (value (by-id "tax"))
        discount (value (by-id "discount"))]
    (set-value! (by-id "total") (-> (* quantity price)
                                    (* (+ 1 (/ tax 100)))
                                    (- discount)
                                    (.toFixed 2)))))

(defn ^:export init []
  (when (and js/document
           (.-getElementById js/document))
    (listen! (by-id "calc")
             :click
             calculate)
    (listen! (by-id "calc")
             :mouseover
             (fn []
               (append! (by-id "shoppingForm")
                        "<div class='help'>Click to calculate</div>")))
    (listen! (by-id "calc")
             :mouseout
             (fn []
               (destroy! (by-class "help"))))))

Few things to be noted about the above code:

  1. depending on your taste, there are more ways to use or require a namespace inside a new namespace declaration. Moreover, what you like while writing code (e.g., minimizing typing) could be different from what you like when reading code (e.g., maximizing readability). When your namespace declaration requires more than a small number of other namespaces, and each namespace has a lot of public symbols, I always prefer to alias the required namespaces, because in a short time my role as a code writer changes very quickly into a code reader and I don't want to get crazy trying to identify which symbol came from which namespace;
  2. the original false boolean value returned by the calculate function has been removed, because the shoppingForm does not have an action property any more;
  3. the original if form has been substituted by the when form, because we now need to do more things when the predicate returns true and there is no else path to be followed;

I hate HTML

I have to admit I'm very bad at both HTML and CSS coding and I always prefer to have a professional designer available to do that job.

If you're like me, you do not want to code any HTML/CSS fragment as a string like we did when we manipulated the DOM to add a div to the shoppingForm form. Debugging such a code could quickly become a PITA.

That's why I searched around to see if someone else, having my same pain, has created a lib to represent those elments as CLJS data structures instead of HTML strings.

hiccups

The first CLJS library I found to relieve my pain was hiccups. Even if it's an incomplete port of hiccup on CLJS, it's solid and stable enough for the purposes of this tutorial. It uses vectors to represent HTML tags and maps to represent a tag's attributes.

Stop the IFDE and add hiccups

Even if we could add a new dependency to IFDE while it's running, as soon as the IFDE exits that dependency is gone. So, to go on with the next step, stop any boot related process and add the hiccups lib into build.boot before starting the IFDE again:

(set-env!
 ...
 :dependencies '[
                  ...
                  [hiccups "0.3.0"]
                 ])

Restart the IFDE

Restart the IFDE as usual:

cd /path/to/modern-cljs
boot dev
...
Compiling ClojureScript...
• main.js
Elapsed time: 23.931 sec

Restart the bREPL

Restart the bREPL as usual:

# from a new terminal
cd /path/to/modern-cljs
boot repl -c
...
boot.user=> (start-repl)
...

and finally visit the shopping URL to activate the bREPL.

Add hiccups namespaces

Open the src/cljs/modern_cljs/shopping.cljs file to update the requirements of the namespace declaration:

(ns modern-cljs.shopping
  (:require [domina.core :refer [append!
                                 by-class
                                 by-id
                                 destroy!
                                 set-value!
                                 value]]
            [domina.events :refer [listen!]]
            [hiccups.runtime])
  (:require-macros [hiccups.core :refer [html]]))

NOTE 1: As noted in the previous tutorial, due to a bug in the boot-cljs-repl task, we need to first require a namespace from a namespace declaration to be able to require it in the bREPL as well.

NOTE 2: The hiccupsruntime namespace has to be required, even if we're not going to use its symbols. For this reason we neither aliased it or refer any symbol.

NOTE 3: the hiccups.core namespace contains macros (e.g. html), which are written in CLJ. Namespaces containing macros are referenced via the :require-macros keyword in the namespace declaration and via require-macros in the bREPL.

As soon as you save the file, the IFDE will recompile and reload it.

bREPLing with hiccups

Before we start bREPLing with hiccups, we need to require its namespace in the bREPL as well:

cljs.user> (require '[hiccups.runtime])
nil
cljs.user> (require-macros '[hiccups.core :refer [html]])
nil

As mentioned above, we only refer to the html macro from the hiccups.core namespace, since it's the only one we're going to use.

Here are some simple examples of using hiccups in the bREPL:

cljs.user> (html [:span {:class "foo"} "bar"])
"<span class=\"foo\">bar</span>"
cljs.user> (html [:script])
"<script></script>"
cljs.user> (html [:p])
"<p />"

hiccups also provides a CSS-like shortcut for denoting id and class attributes:

cljs.user> (html [:div#foo.bar.baz "bang"])
"<div class=\"bar baz\" id=\"foo\">bang</div>"

which brings us to our problem of representing the string "<div class='help'>Click to calculate</div>" as a CLJ data structure to be passed to the append! function:

cljs.user> (html [:div.help "Click to calculate"])
"<div class=\"help\">Click to calculate</div>"

We are now ready to substitute the horrific HTML string to be passed to the mouseover anonymous listener in the shopping.cljs source file:

(defn ^:export init []
  (when (and js/document
           (.-getElementById js/document))
    (listen! (by-id "calc")
             :click
             calculate)
    (listen! (by-id "calc")
             :mouseover
             (fn []
               (append! (by-id "shoppingForm")
                        (html [:div.help "Click to calculate"]))))
    (listen! (by-id "calc")
             :mouseout
             (fn []
               (destroy! (by-class "help"))))))

We're now happy with what we achieved by using domina and hiccups to make our shopping calculator sample as clojure-ish as possible. The only thing that still hurts me is the .-getElementById interop call in the init function. It can be very easily removed by just using aget like so:

(defn ^:export init []
  (when (and js/document
           (aget js/document "getElementById"))
    (listen! (by-id "calc")
             :click
             calculate)
    (listen! (by-id "calc")
             :mouseover
             (fn []
               (append! (by-id "shoppingForm")
                        (html [:div.help "Click to calculate"]))))  ;; hiccups
    (listen! (by-id "calc")
             :mouseout
             (fn []
               (destroy! (by-class "help"))))))

As homework, I suggest you to modify login.cljs according to the approach used for shopping.cljs in this and in the previous tutorial.

You can now stop any boot related process and reset your git repository.

git reset --hard

In the next tutorial we're going to extend our comprehension of CLJS by introducing Ajax to let the CLJS client-side code communicate with the CLJ server-side code.

License

Copyright © Mimmo Cosenza, 2012-15. Released under the Eclipse Public License, the same as Clojure.