Search code examples
javascriptevent-handlingstoppropagationevent-delegationevent-propagation

How to support stopPropagation in delegated event listeners


I've looked at the source code from jQuery to find out how they implemented support for event.stopPropagation() in delegated event listeners, i.e. document.on(event, element...), but can't seem to get my own vanilla JS implementation to fully work.

I tried overriding the native event.stopPropagation() in my method to simply set an event.propagationStopped on the event itself, and looking for this when deciding if the event should propagate up to its parents.

This works when the event listener that stops propagation is attached before other ones, however it does not when it's attached in reverse order, something that does work as expected in jQuery's implementation.

function delegate(type, selector, callback) {
    document.addEventListener(type, function(event) {
        event.stopPropagation = function() {
            event.propagationStopped = true;
        };

        var element = event.target, found;

        while (element && element.parentNode) {
            if (element.matches(selector)) {
                callback.call(element, event);
            }

            if (!event.propagationStopped) {
                element = element.parentNode;
            } else {
                break;
            }
        }
    });
}


delegate('click', '.overlay', function() {
    console.log('Close overlay');
});

delegate('click', '.modal', function(e) {
    e.stopPropagation();

    console.log('Clicked inside modal, stopping propagation...');
});

I want the event to stop its further propagation in all event listeners regardless of the order in which they were attached, instead of the current behaviour.


Solution

  • I think this is only possible by doing the delegated event propagation yourself in a single event listener. In your current implementation, the second listener attached to the document always fires second, regardless of its selector - but when it selects a descendant of the other selector, you need it to fire first.

    Here I'm collecting a list of delegated event listeners, and then iterate that for each element in your parent traversal. You could keep the list on the element itself (e.g. with symbols or a WeakMap), but since you only delegate on document I'll simply store them globally.

    const delegatedListeners = {};
    
    function delegate(type, selector, callback) {
        if (!delegatedListeners[type]) {
            const listeners = delegatedListeners[type] = [];
            document.addEventListener(type, function(event) {
                for (let element = event.target; element && element.parentNode; element = element.parentNode) {
                    // unfortunately, event.currentTarget cannot be overwritten with element
                    for (const {selector, callback} of listeners) {
                        if (element.matches(selector)) {
                            callback.call(element, event);
                        }
                    }
                    if (event.cancelBubble) { // a getter for the propagation stopped flag :-)
                        break;
                    }
                }
            });
        }
        delegatedListeners[type].push({selector, callback});
    }