├── 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 |
12 |
13 |
14 |
15 |
16 |
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 | --------------------------------------------------------------------------------