UNPKG

ripple

Version:

Ripple is an elegant TypeScript UI framework

431 lines (373 loc) 11.2 kB
/** @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; }; }