A few words from Agical

There can be only one!

Here is a stupid simple way to easily manage those UI elements of which there can be only one opened at a time, such as application menus. I call them Highlanders.

My requirements are that I want no extra DOM elements, nor any extra CSS, plus that the user’s intent should be honored, either if it is intentionally closing the element or starting to interact with some other element. (Say the menu is open and the user starts to drag something somewhere else. That dragging should happen and the menu should also close.)

As for the approach here, I am pretty sure the idea is used in a lot of apps out there. It’s almost the first thing that springs to mind. And it really is very simple. Like so:

  1. Have an event handler on the main app element (the element containing all other elements of the app) that closes any Highlander that is opened. Let’s call this the closer.

  2. Make the event handler that opens and closes the Highlander element also

    1. Do the closers job for any opened Highlander

    2. Register the element such that the closer knows what to close

    3. Stop propagation of the event (lest the closer will close the Highlander just opened)

  3. Have an event handler on the Highlander element stop the propagation of the event

In order for the user’s intent to be honored we need to use the mousedown event for opening and closing the menu and all other event handlers in the scheme. But John Carmack tells us that things should happen on press, so this is fine.

The idea is easily implemented for any UI library or framework. However, the implementation presented here is data oriented, and targeted at Dumdom, a tiny ClojureScript library that focuses on letting us use data, and makes it delightful to build web applications.

On top of Dumdom we are using a tiny event loop framework, developed at Anteo, my current client. If you know re-frame, you’ll note that this is similar in spirit. But it is much simpler and way smaller (about 50 loc). The event framework calls a function passing the state and the handler. The handler is a vector identifying the handler and providing any extra arguments. The handlers are not supposed to cause any side effects and also to produce pure data as their result, making them good targets for unit testing. The framework doesn’t care how the event handlers are matched. We use core.match here, matching each handler to its result.

Here are the event handlers relevant for Highlander:

    {:effects [[:ui/fx-event-stop-propagation]]}

    ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; Highlander ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
    ;; There can be only one!
    ;; Menus, etcetera, that should close when the user interacts with
    ;; another element, including another Highlander
    ;; A Highlander is a key in the state to a boolean value

    ;; Highlander closer
    ;; Attach this on the main app container `mousedown` event
    (when-let [highlander (:ui/highlander state)]
      {:new-state (-> state
                      (assoc highlander false)
                      (dissoc :ui/highlander))})

    ;; Highlander toggle
    ;; Attach this to elements that should open or close a highlander
    ;; NB: Must be attached to `mousedown`
    [:ui/ax-highlander-toggle highlander]
    (let [other-highlander (:ui/highlander state)
          open? (highlander state false)]
      {:new-state (cond-> state
                    :always (update highlander not)
                    other-highlander (assoc other-highlander false)
                    other-highlander (dissoc :ui/highlander)
                    (not open?) (assoc :ui/highlander highlander))
       :effects [[:ui/fx-event-stop-propagation]]})

    ;; Highlander shielder
    ;; Attach `ui/ax-event-stop-propagation` to `mousedown` of the Highlander itself,
    ;; to stop the Highlander closer from closing it
    ;; There can be only one!
    ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; Highlander ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

Like with re-frame, we separate the effect handlers. The only effect handler here is [:ui/fx-event-stop-propagation]:

    [:ui/fx-event-stop-propagation] (.stopPropagation event)

We then attach the event handler to the main app container like so:

[:div {:on-mousedown [[:ui/ax-highlander-maybe-close]]}

The element opening any Highlander:

[:div {:on-mousedown [[:ui/ax-highlander-toggle :foo/menu-x-visible?]]}

:foo/menu-x-visible? is the keyword representing the Highlander. Looking up that keyword in the app-db will tell the app if the Highlander element should be visible or not.

The Highlander element:

[:div {:on-mousedown [[:ui/ax-event-stop-propagation]]}

That’s it. Add as many Highlanders as you fancy.

A nice thing with Dumdom is its focus on letting you use data. Our event handlers receive and return pure data, meaning that we can easily test their logic, without any dependency injection ceremony. Here are the Highlander tests:

(deftest highlander
  (testing "Enable a sole Highlander"
    (let [old-state {}]
      (is (= {:new-state {:foo/highlander-a-visible? true
                          :ui/highlander :foo/highlander-a-visible?}
              :effects [[:ui/fx-event-stop-propagation]]}
             (events/handle-action old-state
                                   [:ui/ax-highlander-toggle :foo/highlander-a-visible?]))
          "the first Highlander enters the scene"))
    (let [old-state {:foo/highlander-a-visible? false}]
      (is (= {:new-state {:foo/highlander-a-visible? true
                          :ui/highlander :foo/highlander-a-visible?}
              :effects [[:ui/fx-event-stop-propagation]]}
             (events/handle-action old-state
                                   [:ui/ax-highlander-toggle :foo/highlander-a-visible?]))
          "a Highlander returns to the scene")))

  (testing "Disable a sole Highlander"
    (let [old-state {:foo/highlander-a-visible? true
                     :ui/highlander :foo/highlander-a-visible?}]
      (is (= {:new-state {:foo/highlander-a-visible? false}
              :effects [[:ui/fx-event-stop-propagation]]}
             (events/handle-action old-state
                                   [:ui/ax-highlander-toggle :foo/highlander-a-visible?]))
          "the sole Highlander leaves the scene via its toggler")
      (is (= {:new-state {:foo/highlander-a-visible? false}}
             (events/handle-action old-state
          "the sole Highlander leaves the scene via the Highlander closer")))

  (testing "Enable a Highlander when another is active"
    (let [old-state {:foo/highlander-a-visible? true
                     :ui/highlander :foo/highlander-a-visible?}]
      (is (= {:new-state {:foo/highlander-a-visible? false
                          :foo/highlander-b-visible? true
                          :ui/highlander :foo/highlander-b-visible?}
              :effects [[:ui/fx-event-stop-propagation]]}
             (events/handle-action old-state
                                   [:ui/ax-highlander-toggle :foo/highlander-b-visible?]))
          "there can be only one!"))))

Don’t lose your head!