Skip to content

Commit

Permalink
Merge branch 'new-events'
Browse files Browse the repository at this point in the history
  • Loading branch information
levand committed Apr 27, 2012
2 parents 68370fd + a3a0543 commit b931ed7
Show file tree
Hide file tree
Showing 4 changed files with 442 additions and 211 deletions.
96 changes: 96 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,102 @@ Get the values of all `<input>` elements on the page:

For examples of every currently implemented function, see the `test.cljs` file in the code repository, which exercises each function in unit tests against a DOM page. The `test.html` file loads and runs `test.cljs` in the context of the DOM.

## Event Handling

Domina contains a robust event handling API that wraps the Google Closure event handling code, while exposing it in a idiomatic functional way.

### Event Propagation

In Domina, every event has a *target*. This is the DOM node that is logically "causing" the event. All events triggered by the browser (such as clicks or key presses) are associated with a node. User defined events must also specify a target node.

Event listeners are also attached to nodes, and may trigger on either the *capture* or *bubble* phases of event propegation. The capture phase starts at the root node of the document, and successively fires any listeners on ancestors of the target node from the top down, down to the event target itself. In the bubble phase, the process is reversed, first firing listeners on the target node, then on each of its ancestors in succession back to the document root.

### Registering Event Listeners

Use the `listen!` function to register an event handler in the bubble phase, and `capture!` to register a handle for the capture phase. Both take similar argument: a Domina DomContent, the event type, and a listener function. They return a sequence of event handler keys (see section below on de-registering event handlers)

```clojure
(listen! (sel "button") :click (fn [evt] (log "button clicked!")))
```

This above snippet adds an event handler to every `<button>` element on the page, which logs a message when the button is clicked.

Note that the content argument is optional: in this case, the listener is added to the root node of the document, and will catch all click events on the entire page.

```clojure
(listen! :click (fn [evt] (log "button clicked!")))
```

### Event Objects

When an event is triggered, it invokes the provided listener function, passing it an event object. The event object will implement ClojureScript's `ILookup` protocol, as well as the `domina.events.Event` protocol.

Implementing the `ILookup` protocol makes it easy to pull values from browser events using ClojureScript's built in lookup functions such as `get` and `contains?`, as well as using keywords in function position. Note that although native events only have string keys, Domina will attempt to translate keywords to strings for lookup purposes.

```clojure
(defn sample-click-handler [evt]
(let [x (:clientX evt)
y (:clientY evt)]
(log (str "Click occurred at window coordinates " x "," y))))
```

The `domina.events.Event` protocol supports the following methods:

<table>
<tr><th>Method</th><th>Effect</th></tr>
<tr>
<td>`prevent-default`</td><td>Prevents the default action for an event from firing. For example, if you invoke `prevent-default` on a click event on a link, it will prevent the browser from navigating the browser as it normally would with a clicked link</td>
</tr>
<tr>
<td>`stop-propagation`</td><td>Prevents all future event listeners (in both the bubble and capture phases) from recieving the event.</td>
</tr>
<tr>
<td>`target`</td><td>Returns the target node of the event.</td>
</tr>
<tr>
<td>`current-target`</td><td>Returns the current target of the event (the node to which the current listener was attached).</td>
</tr>
<tr>
<td>`event-type`</td><td>Returns the type of the event</td>
</tr>
<tr>
<td>`raw-event`</td><td>Returns the underlying `goog.events.Event` object, rather than it's Domina wrapper.</td>
</tr>
</table>

### De-registering Event Handlers

There are several ways to de-register an event handler.

If you have they key returned by the registration function, you can de-register the handler by calling the `unlisten-by-key!` function, passing it the key as a single argument.

If you do not have the key in hand, you can remove all listeners from a node (or set of nodes) using the `unlisten!` function. It takes a DomContent, and an optional event type. If the event type is specified, it will only de-register handlers for that type, otherwise it will de-register everything from the specified node.

```clojure
(unlisten! (sel "button") :click) ; removes all click event handlers from all button elements
(unlisten! (sel "button")) ; removes all event handlers of any type from all button elements
```

There are also `listen-once!` and `capture-once!` variants of `listen!` and `capture!` which de-register themselves after the first time they are triggered.

### Custom Events

In addition to native events dispatched by the browser, Domina allows you to create and dispatch arbitary events using the `dispatch!` function.

The `dispatch!` function takes an event target as a DomContent (assumed to be a single node), an event type, and an event map. Keys and values in the event map are merged in to the event object, and can be used to pass arbitrary data to event handlers.

```clojure
(dispatch! (by-id "evt-target") :my-event {:some-key "some value"})
```

The event will be propegated through the capture and bubble phases just like a browser event, and can be caught in the normal way:

```clojure
(listen! (by-id "evt-target") :my-event (fn [evt] (log (:some-key evt))))
```

Note that if you omit the event target when calling `dispatch!` (or when registering a listener), it will default to the root node of the document. This is often desirable when using custom application-wide events that have no logical mapping to any particular location in the DOM tree.

## Important note on browser XPath compatibility (IE and Android).

Internet Explorer does not support DOM Level 3 XPath selectors. In order to utilize the `domina.xpath` namespace, you will need to include a pure-javascript XPath DOM Level 3 implementation.
Expand Down
7 changes: 6 additions & 1 deletion src/cljs/domina.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@
(when (and *debug* (not (= (.-console js/window) js/undefined)))
(.log js/console mesg)))

(defn log [mesg]
(when (.-console js/window)
(.log js/console mesg)))

(defn by-id
"Returns content containing a single node by looking up the given ID"
[id]
Expand Down Expand Up @@ -424,7 +428,8 @@

(defn- array-like?
[obj]
(and (.-length obj)
(and obj
(.-length obj)
(or (.-indexOf obj) (.-item obj))))

(defn normalize-seq
Expand Down
250 changes: 142 additions & 108 deletions src/cljs/domina/events.cljs
Original file line number Diff line number Diff line change
@@ -1,117 +1,151 @@
(ns domina.events
(:require [domina :as domina]
[goog.object :as gobj]
[goog.events :as events]))

;; Restatement of the the GClosure Event API.
(defprotocol Event
(prevent-default [evt] "Prevents the default action, for example a link redirecting to a URL")
(stop-propagation [evt] "Stops event propagation")
(target [evt] "Returns the target of the event")
(current-target [evt] "Returns the object that had the listener attached")
(event-type [evt] "Returns the type of the the event")
(raw-event [evt] "Returns the original GClosure event"))

(def builtin-events (set (map keyword (gobj/getValues events/EventType))))

;;;;;;;;;;;;;;;;;;; Event Wrappers ;;;;;;;;;;;;;;;;;

(defn child-of?
"returns true if the node(child) is a child of parent"
[parent child]
(cond
(not child) false
(identical? parent child) false
(identical? (.-parentNode child) parent) true
:else (recur parent (.-parentNode child))))

(defn- mouse-enter-leave
"this is used to build cross browser versions of :mouseenter and :mouseleave events"
[func]
(fn [e]
(let [re (.-relatedTarget e)
this (.-currentTarget e)]
(when (and
(not (identical? re this))
(not (child-of? this re)))
(func e)))))


(defn- gen-wrapper
"Generic event wrapper that handles listening and cleanup of wrapped events"
[event-key wrapped-key wrapper-func]
(let [obj (new js/Object)
wevent (name wrapped-key)
event (name event-key)]
(set! (.-wrapped-event obj) wevent)
(set! (.-event obj) event)
(set! (.-listen obj)
(fn [elm func capture opt-scope opt-handler]
(let [callback (wrapper-func func)]
(set! (.-listen callback) func)
(set! (.-scope callback) opt-scope)
(set! (.-event callback) event)
(set! (.-capture callback) capture)
(if op-handler
(.listen opt-handler elm wevent callback capture)
(events/listen elm wevent callback capture)))))
(set! (.-unlisten obj)
(fn [elm func capture opt-scope opt-handler]
(let [listeners (if (= capture js/undefined)
(concat (events/getListeners elm wevent false)
(events/getListeners elm wevent true))
(events/getListeners elm wevent capture))]
(dorun
(map (fn [obj]
(let[listener (.-listener obj)
lfunc (.-listen listener)
scope (.-scope listener)
capture (.-capture listener)]
(when (and (or (not func) (= lfunc func))
(or (not opt-scope) (= scope opt-scope)))
(if opt-handler
(.unlisten opt-handler elm wevent listener capture)
(events/unlisten elm wevent listener capture))))) listeners)))))
obj))

(def wrapper-register (atom {}))

;;;;;;;;;;;;;;;;;;; Public API ;;;;;;;;;;;;;;;;;

(defn reg-event-wrapper! [event-key wrapped-key wrapper-func]
(swap! wrapper-register assoc event-key (gen-wrapper event-key wrapped-key wrapper-func)))
(def root-element (.. js/window -document -documentElement))

(defn- find-builtin-type
[evt-type]
(if (contains? builtin-events evt-type)
(name evt-type)
evt-type))

;; The listener function will always return true: we ignore the return value
;; of the user-provided function since we don't want consumers to have to worry
;; about that (user-provided functions are usually just a bunch of side effects)
;; User functions can explicitly take control by calling prevent-default or
;; stop-propegation
(defn- create-listener-function
[f]
(fn [evt]
(f (reify
Event
(prevent-default [_] (.preventDefault evt))
(stop-propagation [_] (.stopPropagation evt))
(target [_] (.-target evt))
(current-target [_] (.-currentTarget evt))
(event-type [_] (.-type evt))
(raw-event [_] evt)
ILookup
(-lookup [o k]
(if-let [val (aget evt k)]
val
(aget evt (name k))))
(-lookup [o k not-found] (or (-lookup o k)
not-found))))
true))

(defn- listen-internal!
[content type listener capture once]
(let [f (create-listener-function listener)
t (find-builtin-type type)]
(doall (for [node (domina/nodes content)]
(if once
(events/listenOnce node t f capture)
(events/listen node t f capture))))))

(defn listen!
"adding an event to the selected nodes"
([nds event func] (listen! nds event func false))
([nds event func capture]
(let [wrapper (@wrapper-register event)]
(doseq [node (domina/nodes nds)]
(if (nil? wrapper)
(events/listen node (name event) func capture)
(events/listenWithWrapper node wrapper func capture))))))

(defn unlisten!
"removing a specific event from the selected nodes"
([nds event func] (unlisten! nds event func false))
([nds event func capture]
(let [wrapper (@wrapper-register event)]
(doseq [node (domina/nodes nds)]
(if (nil? wrapper)
(events/unlisten node (name event) func capture)
(.unlisten wrapper node func capture))))))

(defn remove-listeners!
"removes all listeners for a given set of events on the selected nodes"
[nds & event-list]
(doseq [node (domina/nodes nds)]
(let [map-func #(let [wrapper (@wrapper-register %)]
(if wrapper
(.unlisten wrapper node)
(events/removeAll node (name %))))]
(doall (map map-func event-list)))))

(defn fire-listeners!
"fires the listeners attached to a set of nodes"
[nds event capture event-map]
(let [wrapper (@wrapper-register event)
nevent (if wrapper (.-wrapped-event wrapper) (name event))
event-obj (goog.events.Event. (event-map :type) (event-map :target))]
(set! (.-relatedTarget event-obj) (event-map :related-target))
(doseq [node (domina/nodes nds)]
(events/fireListeners node nevent capture event-obj))))

;;;;;;;;;;;;;;;;;;; Initialization ;;;;;;;;;;;;;;;;;
(reg-event-wrapper! :mouseenter :mouseover mouse-enter-leave)
(reg-event-wrapper! :mouseleave :mouseout mouse-enter-leave)
"Add an event listener to each node in a DomContent. Listens for events during the bubble phase. Returns a sequence of listener keys (one for each item in the content). If content is omitted, binds a listener to the document's root element."
([type listener] (listen! root-element type listener))
([content type listener]
(listen-internal! content type listener false false)))

(defn capture!
"Add an event listener to each node in a DomContent. Listens for events during the capture phase. Returns a sequence of listener keys (one for each item in the content). If content is omitted, binds a listener to the document's root element."
([type listener] (capture! root-element type listener))
([content type listener]
(listen-internal! content type listener true false)))

(defn listen-once!
"Add an event listener to each node in a DomContent. Listens for events during the bubble phase. De-registers the listener after the first time it is invoked. Returns a sequence of listener keys (one for each item in the content). If content is omitted, binds a listener to the document's root element."
([type listener] (listen-once! root-element type listener))
([content type listener]
(listen-internal! content type listener false true)))

(defn capture-once!
"Add an event listener to each node in a DomContent. Listens for events during the capture phase. De-registers the listener after the first time it is invoked. Returns a sequence of listener keys (one for each item in the content). If content is omitted, binds a listener to the document's root element."
([type listener] (capture-once! root-element type listener))
([content type listener]
(listen-internal! content type listener true true)))

(defn unlisten!
"Removes event listeners from each node in the content. If a listener type is supplied, removes only listeners of that type. If content is omitted, it will remove listeners from the document's root element."
([] (unlisten! root-element))
([content]
(doseq [node (domina/nodes content)]
(events/removeAll node)))
([content type]
(let [type (find-builtin-type type)]
(events/removeAll node type))))

(defn- ancestor-nodes
"Returns a seq of a node and its ancestors, starting with the document root."
([n] (ancestor-nodes n (cons n)))
([n so-far]
(if-let [parent (.-parentNode n)]
(recur parent (cons parent so-far))
so-far)))

;; See closure.goog.testing.events.fireBrowserEvent. This function will give us the same
;; bubbling/capturing functionality as W3C DOM level 2 events, on native browser nodes,
;; so we can dispatch against things that do not inherit from goog.event.EventTarget.
(defn dispatch-browser!
"Intended for internal/testing use only. Clients should prefer dispatch!. Dispatches an event as a simulated browser event from the given source node. Emulates capture/bubble behavior. Returns false if any handlers called prevent-default, otherwise true."
[source evt]
(let [ancestors (ancestor-nodes (domina/single-node source))]
;; Capture phase
(doseq [n ancestors]
(when-not (.-propagationStopped n)
(set! (.-currentTarget evt) n)
(events/fireListeners n (.-type evt) true evt)))
;; Bubble phase
(doseq [n (reverse ancestors)]
(when-not (.-propagationStopped n)
(set! (.-currentTarget evt) n)
(events/fireListeners n (.-type evt) false evt)))
(.-returnValue_ evt)))

(defn dispatch-event-target!
"Intended for internal/testing use only. Clients should prefer dispatch!. Dispatches an event using GClosure's event handling. The event source must extend goog.event.EventTarget"
[source evt]
(events/dispatchEvent source evt))

(defn- is-event-target?
"Tests whether the object is a goog.event.EventTarget"
[o]
(and (.-getParentEventTarget o)
(.-dispatchEvent o)))

(defn dispatch!
"Dispatches an event of the given type, adding the values in event map to the event object. Optionally takes an event source. If none is provided, dispatches on the document's root element. Returns false if any handlers called prevent-default, otherwise true."
([type evt-map] (dispatch! root-element type evt-map))
([source type evt-map]
(let [evt (events/Event. (find-builtin-type type))]
(doseq [[k v] evt-map] (aset evt k v))
(if (is-event-target? source)
(dispatch-event-target! source evt)
(dispatch-browser! source evt)))))

(defn unlisten-by-key!
"Given a listener key, removes the listener."
[key]
(events/unlistenByKey key))

(defn get-listeners
"Returns a sequence of event listeners for all the nodes in the
content of a given type."
[content type]
(let [type (find-builtin-type type)]
(mapcat #(events/getListeners % type false) (domina/nodes content))))
Loading

0 comments on commit b931ed7

Please sign in to comment.