ripple
Version:
Ripple is an elegant TypeScript UI framework
431 lines (373 loc) • 11.2 kB
JavaScript
/** @import { AddEventObject, AddEventOptions, ExtendedEventOptions } from '#public'*/
/**
* @typedef {EventTarget & Record<string, any>} DelegatedEventTarget
*/
import {
event_name_from_capture,
is_capture_event,
is_non_delegated,
is_passive_event,
} from '@tsrx/core/runtime/events';
import {
active_block,
active_reaction,
set_active_block,
set_active_reaction,
set_tracking,
tracking,
} from './runtime.js';
import { array_from, define_property, is_array } from '@tsrx/core/runtime/language-helpers';
import { render } from './blocks.js';
/** @type {Set<string>} */
var all_registered_events = new Set();
/** @type {Set<(events: Array<string>) => void>} */
var root_event_handles = new Set();
/** @type {Element | null} */
var root_target = null;
/**
* @param {AddEventOptions} options
* @returns {AddEventListenerOptions}
*/
function get_event_options(options) {
/** @type AddEventListenerOptions */
var event_options = {};
if (options.capture) {
event_options.capture = true;
}
if (options.once) {
event_options.once = true;
}
if (options.passive) {
event_options.passive = true;
}
if (options.signal) {
event_options.signal = options.signal;
}
return event_options;
}
/**
* @param {EventTarget} element
* @param {string} type
* @param {EventListener} handler
* @param {ExtendedEventOptions} [options]
*/
export function on(element, type, handler, options = {}) {
var opts = { ...options };
if (
element === window ||
element === document ||
element === document.body ||
element === root_target ||
element instanceof MediaQueryList ||
/** @type {Element} */ (element).contains(root_target)
) {
opts.delegated = false;
}
var remove_listener = create_event(type, element, handler, opts);
return () => {
remove_listener();
};
}
var last_propagated_event = null;
/**
* @this {EventTarget}
* @param {Event} event
* @returns {void}
*/
export function handle_event_propagation(event) {
var handler_element = this;
var owner_document = /** @type {Node} */ (handler_element).ownerDocument;
var event_name = event.type;
var path = event.composedPath?.() || [];
var current_target = /** @type {null | Element} */ (path[0] || event.target);
last_propagated_event = event;
// composedPath contains list of nodes the event has propagated through.
// We check __root to skip all nodes below it in case this is a
// parent of the __root node, which indicates that there's nested
// mounted apps. In this case we don't want to trigger events multiple times.
var path_idx = 0;
var handled_at = last_propagated_event === event && event.__root;
if (handled_at) {
var at_idx = path.indexOf(handled_at);
if (at_idx !== -1 && (handler_element === document || handler_element === window)) {
// This is the fallback document listener or a window listener, but the event was already handled
// -> ignore, but set handle_at to document/window so that we're resetting the event
// chain in case someone manually dispatches the same event object again.
event.__root = handler_element;
return;
}
// We're deliberately not skipping if the index is higher, because
// someone could create an event programmatically and emit it multiple times,
// in which case we want to handle the whole propagation chain properly each time.
// (this will only be a false negative if the event is dispatched multiple times and
// the fallback document listener isn't reached in between, but that's super rare)
var handler_idx = path.indexOf(handler_element);
if (handler_idx === -1) {
// handle_idx can theoretically be -1 (happened in some JSDOM testing scenarios with an event listener on the window object)
// so guard against that, too, and assume that everything was handled at this point.
return;
}
if (at_idx <= handler_idx) {
path_idx = at_idx;
}
}
current_target = /** @type {Element} */ (path[path_idx] || event.target);
// there can only be one delegated event per element, and we either already handled the current target,
// or this is the very first target in the chain which has a non-delegated listener, in which case it's safe
// to handle a possible delegated event on it later (through the root delegation listener for example).
if (current_target === handler_element) return;
// Proxy currentTarget to correct target
define_property(event, 'currentTarget', {
configurable: true,
get() {
return current_target || owner_document;
},
});
var previous_block = active_block;
var previous_reaction = active_reaction;
var previous_tracking = tracking;
set_active_block(null);
set_active_reaction(null);
set_tracking(false);
try {
/**
* @type {unknown}
*/
var throw_error;
/**
* @type {unknown[]}
*/
var other_errors = [];
while (current_target !== null) {
/** @type {null | Element} */
var parent_element =
current_target.assignedSlot ||
current_target.parentNode ||
/** @type {any} */ (current_target).host ||
null;
try {
var delegated = /** @type {Record<string, any>} */ (current_target)['__' + event_name];
if (delegated !== undefined && !(/** @type {any} */ (current_target).disabled)) {
if (is_array(delegated)) {
for (var i = 0; i < delegated.length; i++) {
delegated[i].call(current_target, event);
}
} else {
delegated.call(current_target, event);
}
}
} catch (error) {
if (throw_error) {
other_errors.push(error);
} else {
throw_error = error;
}
}
if (event.cancelBubble || parent_element === handler_element || parent_element === null) {
break;
}
current_target = parent_element;
}
if (throw_error) {
for (let error of other_errors) {
// Throw the rest of the errors, one-by-one on a microtask
queueMicrotask(() => {
throw error;
});
}
throw throw_error;
}
} finally {
set_active_block(previous_block);
event.__root = handler_element;
// @ts-ignore remove proxy on currentTarget
delete event.currentTarget;
set_active_block(previous_block);
set_active_reaction(previous_reaction);
set_tracking(previous_tracking);
}
}
/**
* @param {string} event_name
* @param {EventTarget} dom
* @param {EventListener} handler
* @param {AddEventOptions} options
* @returns {() => void}
*/
function create_event(event_name, dom, handler, options) {
var is_delegated = true;
if (is_capture_event(event_name)) {
event_name = event_name_from_capture(event_name);
if (!('capture' in options) || options.capture !== false) {
options.capture = true;
}
}
event_name =
options.customName && options.customName?.length
? options.customName
: event_name.toLowerCase();
if (
options.delegated === false ||
options.capture ||
options.passive ||
options.once ||
options.signal ||
is_non_delegated(event_name)
) {
is_delegated = false;
}
if (is_delegated) {
var prop = '__' + event_name;
var target = /** @type {DelegatedEventTarget} */ (dom);
var current = target[prop];
if (current === undefined) {
target[prop] = handler;
} else if (is_array(current)) {
if (!current.includes(handler)) {
current.push(handler);
}
} else {
if (current !== handler) {
target[prop] = [current, handler];
}
}
delegate([event_name]);
return () => {
var handlers = target[prop];
if (is_array(handlers)) {
var filtered = handlers.filter((h) => h !== handler);
target[prop] =
filtered.length === 0 ? undefined : filtered.length === 1 ? filtered[0] : filtered;
} else {
target[prop] = undefined;
}
};
}
/**
* @this {Element}
* @param {Event} event
* @returns {any}
*/
function target_handler(event) {
var previous_block = active_block;
var previous_reaction = active_reaction;
var previous_tracking = tracking;
try {
set_active_block(null);
set_active_reaction(null);
set_tracking(false);
if (!options.capture) {
// Only call in the bubble phase, else delegated events would be called before the capturing events
handle_event_propagation.call(dom, event);
}
if (!event.cancelBubble) {
return handler?.call(/** @type {Element} */ (this), event);
}
} finally {
set_active_block(previous_block);
set_active_reaction(previous_reaction);
set_tracking(previous_tracking);
}
}
var event_options = get_event_options(options);
dom.addEventListener(event_name, target_handler, event_options);
return () => {
dom.removeEventListener(event_name, target_handler, event_options);
};
}
/**
* @param {string} event_name
* @param {Element} dom
* @param {EventListener | AddEventObject} handler
* @returns {() => void}
*/
export function event(event_name, dom, handler) {
/** @type AddEventOptions */
var options = {};
/** @type {EventListener} */
var event_handler;
if (typeof handler === 'object' && 'handleEvent' in handler) {
({ handleEvent: event_handler, ...options } = handler);
} else {
event_handler = handler;
}
return create_event(event_name, dom, event_handler, options);
}
/**
* Reactive version of event that automatically cleans up and re-attaches
* when the handler changes
* @param {string} event_name
* @param {Element} dom
* @param {() => EventListener | AddEventObject} get_handler
*/
export function render_event(event_name, dom, get_handler) {
/** @type {EventListener | AddEventObject | undefined} */
var prev;
/** @type {(() => void) | undefined} */
var remove_listener;
render(() => {
var handler = get_handler();
if (handler !== prev) {
if (remove_listener) {
remove_listener();
remove_listener = undefined;
}
prev = handler;
if (handler) {
remove_listener = event(event_name, dom, handler);
}
}
});
}
/**
* @param {Array<string>} events
* @returns {void}
*/
export function delegate(events) {
for (var i = 0; i < events.length; i++) {
all_registered_events.add(events[i]);
}
for (var fn of root_event_handles) {
fn(events);
}
}
/** @param {Element} target */
export function handle_root_events(target) {
/** @type {Set<string>} */
var registered_events = new Set();
root_target = target;
/**
* @typedef {Object} EventHandleOptions
* @property {boolean} [passive]
*/
/**
* @typedef {(
* events: Array<string>
* ) => void} EventHandle
*/
/** @type {EventHandle} */
var event_handle = (/** @type {Array<string>} */ events) => {
for (var i = 0; i < events.length; i++) {
var event_name = events[i];
if (registered_events.has(event_name)) continue;
registered_events.add(event_name);
/** @type {boolean} */
var passive = is_passive_event(event_name);
/** @type {EventHandleOptions} */
var options = { passive };
target.addEventListener(event_name, handle_event_propagation, options);
}
};
event_handle(array_from(all_registered_events));
root_event_handles.add(event_handle);
return () => {
for (var event_name of registered_events) {
target.removeEventListener(
event_name,
/** @type {EventListener} */ (handle_event_propagation),
);
}
root_event_handles.delete(event_handle);
root_target = null;
};
}