├── spec
├── spec-helper.coffee
└── dom-listener-spec.coffee
├── .gitignore
├── .npmignore
├── LICENSE.md
├── package.json
├── Gruntfile.coffee
├── README.md
└── src
└── dom-listener.coffee
/spec/spec-helper.coffee:
--------------------------------------------------------------------------------
1 | require 'coffee-cache'
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | lib/
3 | .DS_Store
4 | npm-debug.log
5 | *.swp
6 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | spec
2 | script
3 | src
4 | *.coffee
5 | .npmignore
6 | .DS_Store
7 | npm-debug.log
8 | .travis.yml
9 | .pairs
10 | .coffee
11 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2015 GitHub Inc.
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dom-listener",
3 | "version": "0.1.2",
4 | "description": "A listener for delegated DOM events.",
5 | "main": "./lib/dom-listener",
6 | "scripts": {
7 | "prepublish": "grunt clean lint coffee",
8 | "test": "grunt test"
9 | },
10 | "repository": {
11 | "type": "git",
12 | "url": "https://github.com/atom/dom-listener.git"
13 | },
14 | "bugs": {
15 | "url": "https://github.com/atom/dom-listener/issues"
16 | },
17 | "licenses": [
18 | {
19 | "type": "MIT",
20 | "url": "http://github.com/atom/dom-listener/raw/master/LICENSE.md"
21 | }
22 | ],
23 | "dependencies": {
24 | "binary-search": "^1.2.0",
25 | "clear-cut": "^0.3.0",
26 | "event-kit": "^1.0.3"
27 | },
28 | "devDependencies": {
29 | "coffee-script": "^1.7.0",
30 | "jasmine-focused": "^1.0.4",
31 | "grunt-contrib-coffee": "^0.9.0",
32 | "grunt-cli": "^0.1.8",
33 | "grunt": "^0.4.1",
34 | "grunt-shell": "^0.2.2",
35 | "grunt-coffeelint": "^0.0.6",
36 | "rimraf": "^2.2.2",
37 | "coffee-cache": "^0.2.0",
38 | "temp": "^0.6.0"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Gruntfile.coffee:
--------------------------------------------------------------------------------
1 | module.exports = (grunt) ->
2 | grunt.initConfig
3 | pkg: grunt.file.readJSON('package.json')
4 |
5 | coffee:
6 | glob_to_multiple:
7 | expand: true
8 | cwd: 'src'
9 | src: ['**/*.coffee']
10 | dest: 'lib'
11 | ext: '.js'
12 |
13 | coffeelint:
14 | options:
15 | no_empty_param_list:
16 | level: 'error'
17 | max_line_length:
18 | level: 'ignore'
19 | indentation:
20 | level: 'ignore'
21 |
22 | src: ['src/*.coffee']
23 | test: ['spec/*.coffee']
24 | gruntfile: ['Gruntfile.coffee']
25 |
26 | shell:
27 | test:
28 | command: 'node --harmony_collections node_modules/.bin/jasmine-focused --coffee --captureExceptions --forceexit spec'
29 | options:
30 | stdout: true
31 | stderr: true
32 | failOnError: true
33 |
34 | grunt.loadNpmTasks('grunt-contrib-coffee')
35 | grunt.loadNpmTasks('grunt-shell')
36 | grunt.loadNpmTasks('grunt-coffeelint')
37 |
38 | grunt.registerTask 'clean', -> require('rimraf').sync('lib')
39 | grunt.registerTask('lint', ['coffeelint'])
40 | grunt.registerTask('default', ['coffee', 'lint'])
41 | grunt.registerTask('test', ['coffee', 'lint', 'shell:test'])
42 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ##### Atom and all repositories under Atom will be archived on December 15, 2022. Learn more in our [official announcement](https://github.blog/2022-06-08-sunsetting-atom/)
2 | # dom-listener
3 |
4 | This library simplifies the event delegation pattern for DOM events. When you
5 | build a `DOMListener` with a DOM node, you can associate event handles with any
6 | of its descendant nodes via CSS selectors.
7 |
8 | Say you have the following DOM structure.
9 |
10 | ```html
11 |
17 | ```
18 |
19 | Now you can associate a `click` event with all `.grandchild` nodes as follows:
20 |
21 | ```coffee
22 | DOMListener = require 'dom-listener'
23 |
24 | listener = new DOMListener(document.querySelector('.parent'))
25 | listener.add '.grandchild', 'click', (event) -> # handle event...
26 | ```
27 |
28 | ## Selector-Based Handlers
29 |
30 | To create a selector-based handler, call `DOMListener::add` with a selector,
31 | and event name, and a callback. Handlers with selectors matching a given element
32 | will be invoked in order of selector specificity, just like CSS. In the event
33 | of a specificity tie, more recently added handlers will be invoked first.
34 |
35 | ```coffee
36 | listener.add '.child.foo', 'click', (event) -> # handler 1
37 | listener.add '.child', 'click', (event) -> # handler 2
38 | listener.add '.child', 'click', (event) -> # handler 3
39 | ```
40 |
41 | In the example above, all handlers match an event on `.child.foo`, but handler 1
42 | is the most specific, so it will be invoked first. Handlers 2 and 3 are tied in
43 | specificity, so handler 3 is invoked first since it is more recent.
44 |
45 | ## Inline Handlers
46 |
47 | To create event handlers for specific DOM nodes, pass the node rather than a
48 | selector as the first argument to `DOMListener::add`.
49 |
50 | ```coffee
51 | childNode = document.querySelector('.child')
52 | listener.add childNode, 'click', (event) -> # handle inline event...
53 | ```
54 |
55 | This is a bit different than adding the event handler directly via the native
56 | `.addEventListener` method, because only inline handlers registered via
57 | `DOMListener::add` will correctly interleave with selector-based handlers.
58 | Interleaving selector-based handlers with native event listeners isn't possible
59 | without monkey-patching DOM APIs because you can't ask an element what event handlers are registered.
60 |
61 | ## Disposing of Handlers
62 |
63 | If you want to remove an event handler, call `.dispose()` on the `Disposable`
64 | returned from `DOMListener::add`:
65 |
66 | ```coffee
67 | disposable = listener.add 'child', 'click', (event) -> # handle event
68 | disposable.dispose() # remove event handler
69 | ```
70 |
71 | ## Destroying the Listener
72 |
73 | If you want to remove *all* event handlers associated with the listener and
74 | remove its native event listeners, call `DOMListener::destroy()`.
75 |
76 | ```coffee
77 | listener.destroy() # All handlers are removed
78 | ```
79 |
80 | You can add new event handlers and call `.destroy()` again at a later point.
81 |
--------------------------------------------------------------------------------
/src/dom-listener.coffee:
--------------------------------------------------------------------------------
1 | {Disposable} = require 'event-kit'
2 | {specificity} = require 'clear-cut'
3 | search = require 'binary-search'
4 |
5 | SpecificityCache = {}
6 |
7 | module.exports =
8 | class DOMListener
9 | constructor: (@element) ->
10 | @selectorBasedListenersByEventName = {}
11 | @inlineListenersByEventName = {}
12 | @nativeEventListeners = {}
13 |
14 | add: (target, eventName, handler) ->
15 | unless @nativeEventListeners[eventName]
16 | @element.addEventListener(eventName, @dispatchEvent)
17 | @nativeEventListeners[eventName] = true
18 |
19 | if typeof target is 'string'
20 | @addSelectorBasedListener(target, eventName, handler)
21 | else
22 | @addInlineListener(target, eventName, handler)
23 |
24 | destroy: ->
25 | for eventName of @nativeEventListeners
26 | @element.removeEventListener(eventName, @dispatchEvent)
27 | @selectorBasedListenersByEventName = {}
28 | @inlineListenersByEventName = {}
29 | @nativeEventListeners = {}
30 |
31 | addSelectorBasedListener: (selector, eventName, handler) ->
32 | newListener = new SelectorBasedListener(selector, handler)
33 | listeners = (@selectorBasedListenersByEventName[eventName] ?= [])
34 | index = search(listeners, newListener, (a, b) -> b.specificity - a.specificity)
35 | index = -index - 1 if index < 0 # index is negative index minus 1 if no exact match is found
36 | listeners.splice(index, 0, newListener)
37 |
38 | new Disposable ->
39 | index = listeners.indexOf(newListener)
40 | listeners.splice(index, 1)
41 |
42 | addInlineListener: (node, eventName, handler) ->
43 | listenersByNode = (@inlineListenersByEventName[eventName] ?= new WeakMap)
44 | unless listeners = listenersByNode.get(node)
45 | listeners = []
46 | listenersByNode.set(node, listeners)
47 | listeners.push(handler)
48 |
49 | new Disposable ->
50 | index = listeners.indexOf(handler)
51 | listeners.splice(index, 1)
52 |
53 | dispatchEvent: (event) =>
54 | currentTarget = event.target
55 | propagationStopped = false
56 | immediatePropagationStopped = false
57 | defaultPrevented = false
58 |
59 | descriptor = {
60 | type: value: event.type
61 | detail: value: event.detail
62 | eventPhase: value: Event.BUBBLING_PHASE
63 | target: value: currentTarget
64 | currentTarget: get: -> currentTarget
65 | stopPropagation: value: ->
66 | propagationStopped = true
67 | event.stopPropagation()
68 | stopImmediatePropagation: value: ->
69 | propagationStopped = true
70 | immediatePropagationStopped = true
71 | event.stopImmediatePropagation()
72 | preventDefault: value: ->
73 | defaultPrevented = true
74 | event.preventDefault()
75 | defaultPrevented: get: -> defaultPrevented
76 | }
77 |
78 | for key, value of event
79 | descriptor[key] ?= {value}
80 |
81 | syntheticEvent = Object.create(event.constructor.prototype, descriptor)
82 |
83 | loop
84 | inlineListeners = @inlineListenersByEventName[event.type]?.get(currentTarget)
85 | if inlineListeners?
86 | for handler in inlineListeners
87 | handler.call(currentTarget, syntheticEvent)
88 | break if immediatePropagationStopped
89 |
90 | break if immediatePropagationStopped
91 |
92 | selectorBasedListeners = @selectorBasedListenersByEventName[event.type]
93 | if selectorBasedListeners? and typeof currentTarget.matches is 'function'
94 | for listener in selectorBasedListeners when currentTarget.matches(listener.selector)
95 | listener.handler.call(currentTarget, syntheticEvent)
96 | break if immediatePropagationStopped
97 |
98 | break if propagationStopped
99 | break if currentTarget is @element
100 | currentTarget = currentTarget.parentNode
101 |
102 | class SelectorBasedListener
103 | constructor: (@selector, @handler) ->
104 | @specificity = (SpecificityCache[@selector] ?= specificity(@selector))
105 |
--------------------------------------------------------------------------------
/spec/dom-listener-spec.coffee:
--------------------------------------------------------------------------------
1 | DOMListener = require '../src/dom-listener'
2 |
3 | describe "DOMListener", ->
4 | [parent, child, grandchild, listener] = []
5 |
6 | beforeEach ->
7 | grandchild = document.createElement("div")
8 | grandchild.classList.add('grandchild')
9 | child = document.createElement("div")
10 | child.classList.add('child')
11 | parent = document.createElement("div")
12 | parent.classList.add('parent')
13 | child.appendChild(grandchild)
14 | parent.appendChild(child)
15 |
16 | document.querySelector('#jasmine-content').appendChild(parent)
17 |
18 | listener = new DOMListener(parent)
19 |
20 | describe "when an event is dispatched on an element covered by the listener", ->
21 | it "invokes callbacks associated with matching selectors along the event's bubble path", ->
22 | calls = []
23 |
24 | listener.add '.parent', 'event', (event) ->
25 | expect(this).toBe parent
26 | expect(event.type).toBe 'event'
27 | expect(event.detail).toBe 'detail'
28 | expect(event.target).toBe grandchild
29 | expect(event.currentTarget).toBe parent
30 | expect(event.eventPhase).toBe Event.BUBBLING_PHASE
31 | expect(event.customProperty).toBe 'foo'
32 | calls.push('parent')
33 |
34 | listener.add '.child', 'event', (event) ->
35 | expect(this).toBe child
36 | expect(event.type).toBe 'event'
37 | expect(event.detail).toBe 'detail'
38 | expect(event.target).toBe grandchild
39 | expect(event.currentTarget).toBe child
40 | expect(event.eventPhase).toBe Event.BUBBLING_PHASE
41 | expect(event.customProperty).toBe 'foo'
42 | calls.push('child')
43 |
44 | listener.add '.grandchild', 'event', (event) ->
45 | expect(this).toBe grandchild
46 | expect(event.type).toBe 'event'
47 | expect(event.detail).toBe 'detail'
48 | expect(event.target).toBe grandchild
49 | expect(event.currentTarget).toBe grandchild
50 | expect(event.eventPhase).toBe Event.BUBBLING_PHASE
51 | expect(event.customProperty).toBe 'foo'
52 | calls.push('grandchild')
53 |
54 | dispatchedEvent = new CustomEvent('event', bubbles: true, detail: 'detail')
55 | dispatchedEvent.customProperty = 'foo'
56 | grandchild.dispatchEvent(dispatchedEvent)
57 |
58 | expect(calls).toEqual ['grandchild', 'child', 'parent']
59 |
60 | it "invokes multiple matching callbacks for the same element by selector specificity, then recency", ->
61 | child.classList.add('foo', 'bar')
62 | calls = []
63 |
64 | listener.add '.child.foo.bar', 'event', -> calls.push('b')
65 | listener.add '.child.foo.bar', 'event', -> calls.push('a')
66 | listener.add '.child.foo', 'event', -> calls.push('c')
67 | listener.add '.child', 'event', -> calls.push('d')
68 |
69 | grandchild.dispatchEvent(new CustomEvent('event', bubbles: true))
70 |
71 | expect(calls).toEqual ['a', 'b', 'c', 'd']
72 |
73 | it "invokes inline listeners before selector-based listeners", ->
74 | calls = []
75 |
76 | listener.add '.grandchild', 'event', -> calls.push('grandchild selector')
77 | listener.add child, 'event', (event) ->
78 | expect(event.eventPhase).toBe Event.BUBBLING_PHASE
79 | expect(event.currentTarget).toBe child
80 | expect(event.target).toBe grandchild
81 | calls.push('child inline 1')
82 | listener.add child, 'event', (event) ->
83 | expect(event.eventPhase).toBe Event.BUBBLING_PHASE
84 | expect(event.currentTarget).toBe child
85 | expect(event.target).toBe grandchild
86 | calls.push('child inline 2')
87 | listener.add '.child', 'event', -> calls.push('child selector')
88 |
89 | grandchild.dispatchEvent(new CustomEvent('event', bubbles: true))
90 |
91 | expect(calls).toEqual ['grandchild selector', 'child inline 1', 'child inline 2', 'child selector']
92 |
93 | it "stops invoking listeners on ancestors when .stopPropagation() is called on the synthetic event", ->
94 | calls = []
95 | listener.add '.parent', 'event', -> calls.push('parent')
96 | listener.add '.child', 'event', (event) -> calls.push('child'); event.stopPropagation()
97 | listener.add '.grandchild', 'event', -> calls.push('grandchild')
98 |
99 | dispatchedEvent = new CustomEvent('event', bubbles: true)
100 | spyOn(dispatchedEvent, 'stopPropagation')
101 | grandchild.dispatchEvent(dispatchedEvent)
102 |
103 | expect(calls).toEqual ['grandchild', 'child']
104 | expect(dispatchedEvent.stopPropagation).toHaveBeenCalled()
105 |
106 | it "stops invoking listeners entirely when .stopImmediatePropagation() is called on the synthetic event", ->
107 | calls = []
108 | listener.add '.parent', 'event', -> calls.push('parent')
109 | listener.add '.child', 'event', -> calls.push('child 2')
110 | listener.add '.child', 'event', (event) -> calls.push('child 1'); event.stopImmediatePropagation()
111 | listener.add '.grandchild', 'event', -> calls.push('grandchild')
112 |
113 | dispatchedEvent = new CustomEvent('event', bubbles: true)
114 | spyOn(dispatchedEvent, 'stopImmediatePropagation')
115 | grandchild.dispatchEvent(dispatchedEvent)
116 |
117 | expect(calls).toEqual ['grandchild', 'child 1']
118 | expect(dispatchedEvent.stopImmediatePropagation).toHaveBeenCalled()
119 | calls = []
120 |
121 | # also works on inline listeners
122 | listener.add child, 'event', (event) -> calls.push('inline child'); event.stopImmediatePropagation()
123 |
124 | dispatchedEvent = new CustomEvent('event', bubbles: true)
125 | spyOn(dispatchedEvent, 'stopImmediatePropagation')
126 | grandchild.dispatchEvent(dispatchedEvent)
127 | expect(calls).toEqual ['grandchild', 'inline child']
128 | expect(dispatchedEvent.stopImmediatePropagation).toHaveBeenCalled()
129 |
130 | it "forwards .preventDefault() calls to the original event", ->
131 | listener.add '.child', 'event', (event) ->
132 | event.preventDefault()
133 | expect(event.defaultPrevented).toBe true
134 |
135 | dispatchedEvent = new CustomEvent('event', bubbles: true)
136 | spyOn(dispatchedEvent, 'preventDefault')
137 | grandchild.dispatchEvent(dispatchedEvent)
138 | expect(dispatchedEvent.preventDefault).toHaveBeenCalled()
139 |
140 | it "allows listeners to be removed via disposables returned from ::add", ->
141 | calls = []
142 |
143 | disposable1 = listener.add '.child', 'event', -> calls.push('selector 1')
144 | disposable2 = listener.add '.child', 'event', -> calls.push('selector 2')
145 | disposable3 = listener.add child, 'event', -> calls.push('inline 1')
146 | disposable4 = listener.add child, 'event', -> calls.push('inline 2')
147 |
148 | disposable2.dispose()
149 | disposable4.dispose()
150 |
151 | grandchild.dispatchEvent(new CustomEvent('event', bubbles: true))
152 |
153 | expect(calls).toEqual ['inline 1', 'selector 1']
154 |
155 | it "removes all listeners when DOMListener::destroy() is called", ->
156 | calls = []
157 | listener.add '.child', 'event', -> calls.push('selector')
158 | listener.add child, 'event', -> calls.push('inline')
159 | listener.destroy()
160 | grandchild.dispatchEvent(new CustomEvent('event', bubbles: true))
161 | expect(calls).toEqual []
162 |
--------------------------------------------------------------------------------