Hey, just curious, but how do people go about handling things like context menus in a re-frame application? I'm not sure how I want to catch clicks outside the component to dismiss it.
Not really related to re-frame itself. Two common approaches: 1. A backdrop element that covers the whole screen that, when clicked, sends some kind of signal that the associated menu should be closed 2. A global click listener that checks whether there is some menu that it closes upon firing
I was wondering more along the lines of how people choose to store that bit of info in their app-db.
Like, do you have a set of popup toggle states in your app-db so that a generic handler can clear them easily, or per-popup ones so you need to know where to find them, etc.
If you go the first route, the backdrop is controlled by the context menu. So whatever state in app-db drives the context menu would inextricably also drive the backdrop. If you go the second route - yeah, there must be some coordination, some common knowledge. So the list of all open pop-ups has to be stored in a central location. Could be in app-db if that list would affect something else, could be a separate atom. I myself prefer going the first route anyway. No state to track.
Here's how I do it. I use re-frame and reagent in my app, but for this I don't use re-frame at all. Also for this I don't use a backdrop element (I do use a full screen backdrop element when displaying a modal). I use reagent and I toggle visibility using a local reagent atom showing?. The CSS classes used here (.dropdown, .dropdown-trigger, etc.) don't really play a role in the functionality, they just define things like position: relative and z-index.
(ns elements.dropdown
(:require [reagent.core :as r]))
(defn escape? [e]
(let [e (or e (.event js/window))]
(= "Escape" (.-key e))))
(defn element
"Provide a 'trigger' that takes a fn 'toggle' to hide/show the dropdown.
Also provide 'content' that takes a fn 'close'."
[trigger content]
(r/with-let [showing? (r/atom false)
toggle (fn [] (swap! showing? not))
close (fn [_e] (reset! showing? false))
close-escape (fn [e] (when (escape? e) (close e)))
_ (.addEventListener js/document "keydown" close-escape)
_ (.addEventListener js/document "click" close)]
[:div.dropdown
{:on-click #(.stopPropagation %)} ;; prevent a click from reaching global listener
[:div.dropdown-trigger
[trigger toggle]]
(when @showing?
[:div.dropdown-content
[content close]])]
(finally
(.removeEventListener js/document "keydown" close-escape)
(.removeEventListener js/document "click" close))))
Then where I want to use the dropdown...
(ns app
(:require
[elements.dropdown :as dropdown]))
(defn trigger [toggle]
... somewhere in here call `toggle` to hide/show ...
)
(defn content [close]
... can call `close` if needed ...
)
(defn app []
...
[dropdown/element trigger content]
...
)I use a hook that detects clicks outside, inside and handles pressing escape as well. The hook is attached as a :ref to a div. And the div is basically a div that wraps around the component you want to show this behaviour. Works for modals, side panels , context menus etc.
(ns app.components.generic.hooks.use-click-away
(:require
["react" :refer [useEffect useRef]]))
(defn use-click-away
"Hook-style utility. Returns a `ref` to attach to an element. Dispatches `close-event` on:
- outside click,
- Escape key,
- internal click on element with `data-close-on-click`."
[close-fn]
(let [ref (useRef nil)]
(useEffect
(fn []
(let [handle-click-away (fn [e]
(when (and (.-current ref)
(not (.contains (.-current ref) (.-target e)))
(not (.closest (.-target e) ".modal")))
(.stopPropagation e)
(close-fn)))
handle-escape (fn [e]
(when (= "Escape" (.-key e))
(.stopPropagation e)
(close-fn)))
handle-inner-click (fn [e]
(when (.hasAttribute (.-target e) "data-close-on-click")
(.stopPropagation e)
(close-fn)))]
;; Attach listeners
(.addEventListener js/document "mousedown" handle-click-away)
(.addEventListener js/document "keydown" handle-escape)
(when (.-current ref)
(.addEventListener (.-current ref) "click" handle-inner-click))
;; Cleanup
(fn []
(.removeEventListener js/document "mousedown" handle-click-away)
(.removeEventListener js/document "keydown" handle-escape)
(when (.-current ref)
(.removeEventListener (.-current ref) "click" handle-inner-click)))))
#js [(.-current ref)]) ; Only re-run effect if ref changes
;; Return ref to attach to DOM element
ref))