Source rapidcontext/ui/event.mjs

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2023 Per Cederberg. All rights reserved.
4 *
5 * This program is free software: you can redistribute it and/or
6 * modify it under the terms of the BSD license.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11 *
12 * See the RapidContext LICENSE for more details.
13 */
14
15let listeners = [];
16
17/**
18 * Provides simplified event handling for DOM nodes. Used as a
19 * mixin into RapidContext.Widget instances and similar, but also
20 * provides static versions of the same functions.
21 *
22 * @class RapidContext.UI.Event
23 */
24export class Event {
25
26    /**
27     * Dispatches a single event from this DOM node. Also creates
28     * a new CustomEvent instance if needed.
29     *
30     * @method emit
31     * @memberof RapidContext.UI.Event.prototype
32     * @param {string/Event} event the event type name or instance
33     * @param {Object} [opts] the event options
34     * @param {boolean} [opts.async=true] the async dispatch flag
35     * @param {boolean} [opts.bubbles=false] the event bubbles flag
36     * @param {boolean} [opts.cancelable=false] the cancellable event flag
37     * @param {Object} [opts.detail] the additional event details
38     * @return {boolean} `true` if event was async or not cancelled
39     */
40    emit(event, opts) {
41        return emit(this, event, opts);
42    }
43
44    /**
45     * Attaches a listener function for one or more events.
46     *
47     * @method on
48     * @memberof RapidContext.UI.Event.prototype
49     * @param {string} event the event type name (or space separated names)
50     * @param {string} [selector] the CSS selector to match for event target
51     * @param {function} listener the event handler function (or `false`)
52     * @param {Object} [opts] the event listener options (see addEventListener)
53     * @param {number} [opts.delay] an inactivity delay before calling listener
54     * @return {Node} the input DOM node (for chaining calls)
55     */
56    on(event, selector, listener, opts) {
57        return on(this, event, selector, listener, opts);
58    }
59
60    /**
61     * Attaches a single event listener function. The listener will
62     * be removed the first time an event is triggered.
63     *
64     * @method once
65     * @memberof RapidContext.UI.Event.prototype
66     * @param {string} event the event type name (or space separated names)
67     * @param {string} [selector] the CSS selector to match for event target
68     * @param {function} listener the event handler function (or `false`)
69     * @param {Object} [opts] the event listener options (see addEventListener)
70     * @param {number} [opts.delay] an inactivity delay before calling listener
71     * @return {Node} the input DOM node (for chaining calls)
72     */
73    once(event, selector, listener, opts) {
74        return once(this, event, selector, listener, opts);
75    }
76
77    /**
78     * Removes one or more previously attached event listeners. If
79     * no event details or listeners are specified, all matching
80     * handlers are removed.
81     *
82     * @method off
83     * @memberof RapidContext.UI.Event.prototype
84     * @param {string} [event] the event type name (or space separated names)
85     * @param {string} [selector] the CSS selector to match for event target
86     * @param {function} [listener] the event handler function (or `false`)
87     * @return {Node} the input DOM node (for chaining calls)
88     */
89    off(event, selector, listener) {
90        return off(this, event, selector, listener);
91    }
92}
93
94/**
95 * Dispatches a single event for a DOM node. Also creates a new
96 * CustomEvent instance if needed.
97 *
98 * @param {Node} src the DOM node emitting the event
99 * @param {string/Event} event the event type name or instance
100 * @param {Object} [opts] the event options
101 * @param {boolean} [opts.async=true] the async dispatch flag
102 * @param {boolean} [opts.bubbles=false] the event bubbles flag
103 * @param {boolean} [opts.cancelable=false] the cancellable event flag
104 * @param {Object} [opts.detail] the additional event details
105 * @return {boolean} `true` if event was async or not cancelled
106 * @function RapidContext.UI.Event.emit
107 */
108export function emit(src, event, opts) {
109    opts = Object.assign({ async: true }, opts);
110    event = (event instanceof window.Event) ? event : new CustomEvent(event, opts);
111    if (opts.async) {
112        setTimeout(() => src.dispatchEvent(event));
113        return true;
114    } else {
115        return src.dispatchEvent(event);
116    }
117}
118
119/**
120 * Attaches a listener function for one or more events.
121 *
122 * @param {Node} src the DOM node when event handler is attached
123 * @param {string} event the event type name (or space separated names)
124 * @param {string} [selector] the CSS selector to match for event target
125 * @param {function} listener the event handler function (or `false`)
126 * @param {Object} [opts] the event listener options (see addEventListener)
127 * @param {number} [opts.delay] an inactivity delay before calling listener
128 * @return {Node} the input DOM node (for chaining calls)
129 * @function RapidContext.UI.Event.on
130 */
131export function on(src, event, selector, listener, opts) {
132    if (typeof(selector) == 'function') {
133        opts = arguments[3];
134        listener = arguments[2];
135        selector = null;
136    }
137    let handler = (listener === false) ? stop : listener;
138    if (opts && opts.delay) {
139        handler = debounce(opts.delay, handler);
140    }
141    if (selector) {
142        handler = delegate.bind(null, selector, handler);
143    }
144    let arr = Array.isArray(event) ? event : event.split(/\s+/g);
145    arr.forEach((event) => {
146        src.addEventListener(event, handler, opts);
147        listeners.push({ src, event, selector, listener, handler, opts });
148    });
149    return src;
150}
151
152/**
153 * Attaches a single event listener function. The listener will
154 * be removed the first time an event is triggered.
155 *
156 * @param {Node} src the DOM node when event handler is attached
157 * @param {string} event the event type name (or space separated names)
158 * @param {string} [selector] the CSS selector to match for event target
159 * @param {function} listener the event handler function (or `false`)
160 * @param {Object} [opts] the event listener options (see addEventListener)
161 * @param {number} [opts.delay] an inactivity delay before calling listener
162 * @return {Node} the input DOM node (for chaining calls)
163 * @function RapidContext.UI.Event.once
164 */
165export function once(src, event, selector, listener, opts) {
166    function handler(evt) {
167        off(src, event, selector, handler);
168        listener.call(this, evt);
169    }
170    return on(src, event, selector, handler, opts);
171}
172
173/**
174 * Removes one or more previously attached event listeners. If
175 * no event details or listeners are specified, all matching
176 * handlers are removed.
177 *
178 * @param {Node} src the DOM node when event handler is attached
179 * @param {string} [event] the event type name (or space separated names)
180 * @param {string} [selector] the CSS selector to match for event target
181 * @param {function} [listener] the event handler function (or `false`)
182 * @return {Node} the input DOM node (for chaining calls)
183 * @function RapidContext.UI.Event.off
184 */
185export function off(src, event, selector, listener) {
186    if (typeof(selector) == 'function') {
187        listener = arguments[2];
188        selector = null;
189    }
190    let arr = (event == null || Array.isArray(event)) ? event : event.split(/\s+/g);
191    let matches = listeners.filter((l) => {
192        return src === l.src &&
193               (arr == null || arr.includes(l.event)) &&
194               (selector == null || selector === l.selector) &&
195               (listener == null || listener === l.listener);
196    });
197    matches.forEach((l) => {
198        src.removeEventListener(l.event, l.handler, l.opts);
199        listeners.splice(listeners.indexOf(l), 1);
200    });
201    return src;
202}
203
204function parents(el, ancestor) {
205    let path = [];
206    for (; el && el !== ancestor; el = el.parentElement) {
207        path.push(el);
208    }
209    el && path.push(el);
210    return path;
211}
212
213function delegate(selector, listener, evt) {
214    let isMatch = (el) => el.matches(selector);
215    let forward = (el) => {
216        try {
217            evt.delegateTarget = el;
218            listener.call(evt.currentTarget, evt);
219        } finally {
220            delete evt.delegateTarget;
221        }
222    };
223    parents(evt.target, evt.currentTarget).filter(isMatch).forEach(forward);
224}
225
226function stop(evt) {
227    evt.preventDefault();
228    evt.stopImmediatePropagation();
229}
230
231function debounce(delay, fn, thisObj) {
232    let timer;
233    return function () {
234        clearTimeout(timer);
235        timer = setTimeout(fn.bind(thisObj || this, arguments), delay);
236    };
237}
238
239export default Object.assign(Event, { on, once, off, emit });
240