ripple
Version:
Ripple is an elegant TypeScript UI framework
616 lines (548 loc) • 17.1 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 { define_property, is_array } from '@tsrx/core/runtime/language-helpers';
import { render } from './blocks.js';
/** @type {Set<string>} */
var all_registered_events = new Set();
/**
* A root delegation target: its acquire count, the event names it listens
* for, and the element.
* @typedef {{ n: number, e: Set<string>, t: Element }} RootTargetRef
*/
/**
* Active root delegation targets, ref-counted per target. Multiple callers of
* `handle_root_events` can share one target element — sibling Portals both
* targeting `document.body`, a Portal targeting the app's mount target, or
* nested mounts. The shared delegated listeners for a target may only be torn
* down once every caller for that target has released it, otherwise
* unmounting one Portal silently kills event delegation for its siblings.
* @type {Map<Element, RootTargetRef>}
*/
var root_target_refs = new Map();
/** @type {RootTargetRef | null} */
var last_root_ref = null;
/**
* Delegated handling only works for elements strictly below a root delegation
* target: the root listener's propagation walk never visits the target itself
* or anything above it, so a delegated handler stored there would never fire.
* @param {EventTarget} element
*/
function is_root_target_or_above(element) {
for (var target of root_target_refs.keys()) {
if (element === target || /** @type {Element} */ (element).contains?.(target)) {
return true;
}
}
return false;
}
/**
* @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 instanceof MediaQueryList ||
is_root_target_or_above(element)
) {
opts.delegated = false;
}
var remove_listener = create_event(type, element, handler, opts);
return () => {
remove_listener();
};
}
/**
* @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;
/** @type {null | Element} */
var current_target;
// __root marks the root that already handled this event: this listener
// then belongs to a parent of a nested mounted app, and the walk resumes
// from that root so nothing below it handles the event twice. Only then
// is the composed path needed; a plain event starts at its target (the
// composed target when it was retargeted out of a shadow tree).
var handled_at = event.__root;
if (handled_at) {
var path = event.composedPath?.() || [];
var path_idx = 0;
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);
} else {
current_target = /** @type {Element} */ (event.target);
if (current_target !== null && /** @type {any} */ (current_target).shadowRoot) {
current_target = /** @type {Element} */ (event.composedPath?.()[0] || current_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[] | null}
*/
var other_errors = null;
var prop = '__' + event_name;
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)[prop];
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) {
if (other_errors !== null) {
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 {
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);
add_delegated_handler(target, prop, handler);
delegate([event_name]);
return () => {
remove_delegated_handler(target, prop, handler);
};
}
/**
* @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);
};
}
/**
* Delegated event name resolution per compiler-emitted name (`"Click"`,
* `"PointerMove"`, ...): the lowercase DOM name, or null when the event cannot
* be delegated (capture variants, non-bubbling events).
* @type {Map<string, string | null>}
*/
var delegated_event_names = new Map();
/**
* @param {string} event_name
* @returns {string | null}
*/
function get_delegated_event_name(event_name) {
var name = delegated_event_names.get(event_name);
if (name === undefined) {
if (is_capture_event(event_name)) {
name = null;
} else {
name = event_name.toLowerCase();
if (is_non_delegated(name)) {
name = null;
}
}
delegated_event_names.set(event_name, name);
}
return name;
}
/**
* @param {DelegatedEventTarget} target
* @param {string} prop
* @param {EventListener} handler
* @returns {void}
*/
function add_delegated_handler(target, prop, handler) {
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];
}
}
}
/**
* @param {DelegatedEventTarget} target
* @param {string} prop
* @param {EventListener} handler
* @returns {void}
*/
function remove_delegated_handler(target, prop, handler) {
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;
}
}
/**
* Compiler-emitted one-shot listener for a static `onEvent={handler}`: the
* element's lifetime owns the handler, so nothing is returned for cleanup.
* @param {string} event_name
* @param {Element} dom
* @param {EventListener | AddEventObject} handler
* @returns {void}
*/
export function event(event_name, dom, handler) {
if (typeof handler === 'function') {
// Plain handler: the common compiled `onClick={fn}` shape. Resolve the
// delegated name from the cache and skip the options plumbing.
var name = get_delegated_event_name(event_name);
if (name !== null) {
add_delegated_handler(/** @type {DelegatedEventTarget} */ (dom), '__' + name, handler);
delegate([name]);
return;
}
}
event_listener(event_name, dom, handler);
}
/**
* Compiler-emitted listener for a static `onEvent={fn}` whose name can never
* be delegated (`onBlur`, `onScroll`, a `Capture` variant): a native listener
* on the element, with the handler run outside any reactive context as a
* delegated handler is. Nothing is returned, as for `event()`. The name is the
* DOM event name the compiler resolved, so no name table is consulted here.
* @param {string} name
* @param {EventTarget} dom
* @param {EventListener} handler
* @param {boolean} [capture]
* @returns {void}
*/
export function listen(name, dom, handler, capture) {
dom.addEventListener(
name,
/**
* @this {EventTarget}
* @param {Event} event
*/
function (event) {
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 {
return handler.call(this, event);
} finally {
set_active_block(previous_block);
set_active_reaction(previous_reaction);
set_tracking(previous_tracking);
}
},
capture,
);
}
/**
* Attaches a listener and returns its remover; `event()` is the one-shot
* compiled form of this.
* @param {string} event_name
* @param {Element} dom
* @param {EventListener | AddEventObject} handler
* @returns {() => void}
*/
export function event_listener(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 = /** @type {EventListener} */ (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_listener(event_name, dom, handler);
}
}
});
}
/**
* @param {Array<string>} events
* @returns {void}
*/
export function delegate(events) {
var added = false;
for (var i = 0; i < events.length; i++) {
var event_name = events[i];
if (!all_registered_events.has(event_name)) {
all_registered_events.add(event_name);
added = true;
}
}
// Every root target already listens for every previously registered event,
// so only a new event name needs to fan out to the targets.
if (added) {
for (var ref of root_target_refs.values()) {
register_root_events(ref, events);
}
}
}
var event_prototype_prepared = false;
/**
* @param {RootTargetRef} ref
* @param {Iterable<string>} events
*/
function register_root_events(ref, events) {
var registered_events = ref.e;
var target = ref.t;
for (var event_name of events) {
if (registered_events.has(event_name)) continue;
if (!event_prototype_prepared) {
event_prototype_prepared = true;
// `__root` is read on every delegated event: declared on the
// prototype, the read stops at Event instead of walking to Object.
// Done with the first listener, so a mount before any delegated
// event is registered never touches `Event.prototype`.
Event.prototype.__root = undefined;
}
registered_events.add(event_name);
target.addEventListener(event_name, handle_event_propagation, {
passive: is_passive_event(event_name),
});
}
}
/**
* Acquires the delegated root listeners for `target` and returns the shared
* ref; every acquire must be paired with one `release_root_events` call.
* @param {Element} target
* @returns {RootTargetRef}
*/
export function handle_root_events(target) {
// Sibling portals mostly share one target, so check the last ref first.
/** @type {RootTargetRef | undefined} */
var ref =
last_root_ref !== null && last_root_ref.t === target
? last_root_ref
: root_target_refs.get(target);
if (ref === undefined) {
ref = { n: 0, e: new Set(), t: target };
root_target_refs.set(target, ref);
// Before any delegated event is registered (a first mount) there is
// nothing to attach.
if (all_registered_events.size > 0) {
register_root_events(ref, all_registered_events);
}
}
ref.n += 1;
last_root_ref = ref;
return ref;
}
/**
* @param {RootTargetRef} ref
* @returns {void}
*/
export function release_root_events(ref) {
// The map entry for `ref.t` is always this `ref`: it is only deleted
// when the count hits 0, which requires every acquirer to have released.
ref.n -= 1;
if (ref.n > 0) return;
// Last caller for this target: actually tear down the shared listeners.
var target = ref.t;
root_target_refs.delete(target);
if (last_root_ref === ref) {
last_root_ref = null;
}
for (var event_name of ref.e) {
target.removeEventListener(event_name, /** @type {EventListener} */ (handle_event_propagation));
}
}