UNPKG

ripple

Version:

Ripple is an elegant TypeScript UI framework

447 lines (407 loc) • 13.2 kB
/** @import { Block } from '#client' */ import { block, branch, destroy_block, ref } from './blocks.js'; import { DESTROYED, REF_PROP, RENDER_BLOCK, TRACKED_OBJECT } from './constants.js'; import { isRefProp as is_ref_prop } from '@tsrx/core/runtime/ref'; import { is_ripple_object } from './utils.js'; import { get_descriptors, get_own_property_symbols, get_prototype_of, } from '@tsrx/core/runtime/language-helpers'; import { event_listener } from './events.js'; import { get_attribute_event_name, is_event_attribute } from '@tsrx/core/runtime/events'; import { get } from './runtime.js'; import { normalize_css_property_name } from '@tsrx/core/runtime/html'; import { set_class_value } from './render.js'; /** * Dynamic attributes, styles and spreads. Apart from `render.js`, so that an * app whose attributes are all static or class/text/value writes never loads * the attribute tables these helpers consult. */ /** @type {Map<string, string[]>} */ var setters_cache = new Map(); /** * @param {Element} element * @returns {string[]} */ function get_setters(element) { var setters = setters_cache.get(element.nodeName); if (setters) return setters; setters_cache.set(element.nodeName, (setters = [])); var descriptors; var proto = element; // In the case of custom elements there might be setters on the instance var element_proto = Element.prototype; // Stop at Element, from there on there's only unnecessary setters we're not interested in // Do not use constructor.name here as that's unreliable in some browser environments while (element_proto !== proto) { descriptors = get_descriptors(proto); for (var key in descriptors) { if (descriptors[key].set) { setters.push(key); } } proto = get_prototype_of(proto); } return setters; } /** * @param {Element} element * @param {any} value * @param {Record<string, string | number | null | undefined> | undefined} prev * @returns {void} */ export function set_style(element, value, prev = {}) { if (value == null) { element.removeAttribute('style'); } else if (typeof value !== 'string') { apply_styles(/** @type {HTMLElement} */ (element), value, prev); } else { // @ts-ignore element.style.cssText = value; } } /** * @param {Element} element * @param {string} attribute * @param {any} value * @returns {void} */ export function set_attribute(element, attribute, value) { if (value == null) { element.removeAttribute(attribute); } else if ( typeof value !== 'string' && // A `data-` or `aria-` name is never a property: the setter walk it // would trigger reads every descriptor of the element's prototypes. !attribute.startsWith('data-') && !attribute.startsWith('aria-') && get_setters(element).includes(attribute) ) { /** @type {any} */ (element)[attribute] = value; } else { element.setAttribute(attribute, value); } } /** * @param {HTMLElement} element * @param {Record<string, string | number | null | undefined>} new_styles * @param {Record<string, string | number | null | undefined>} prev */ function apply_styles(element, new_styles, prev) { const style = element.style; // Apply new styles for (const key in new_styles) { const css_prop = normalize_css_property_name(key); const raw_value = new_styles[key]; const value = raw_value == null ? null : String(raw_value); if (!(key in prev) || prev[key] !== value) { style.setProperty(css_prop, value); } } // Remove properties that were in prev but not in new_styles for (const key in prev) { if (!(key in new_styles)) { const css_prop = normalize_css_property_name(key); style.removeProperty(css_prop); } } } /** * Helper function to set a single attribute * @param {Element} element * @param {string} key * @param {any} value * @param {SpreadState} state */ function set_attribute_helper(element, key, value, state) { if (key === 'class') { const is_html = element.namespaceURI === 'http://www.w3.org/1999/xhtml'; set_class_value(/** @type {HTMLElement} */ (element), value, undefined, is_html); } else if (key === 'innerHTML') { element.removeAttribute('innerhtml'); /** @type {HTMLElement} */ (element).innerHTML = value == null ? '' : String(value); } else if (key === 'style') { set_style(element, value, state.p.style); } else if (key === '#class') { // Special case for the scope classes of an element that spreads props: // one token per scope hash and applied theme. element.classList.add(...String(value).split(' ').filter(Boolean)); } else if (is_event_attribute(key)) { // Handle event handlers in spread props var listeners = (state.l ??= {}); if (listeners[key]) { listeners[key](); listeners[key] = undefined; } if (value != null) { const event_name = get_attribute_event_name(key, value); listeners[key] = event_listener(event_name, element, value); } } else { set_attribute(element, key, value); } } /** * The state of one element spread: the object and values it last applied, * and the listeners and ref effects it owns. `spread()` creates it on its * first call and is handed it back by every later one, so the compiled render * function keeps it in its own state object, and a `render_spread` block in * its block state. */ export class SpreadState { /** @param {Element} element */ constructor(element) { this.e = element; /** @type {Record<string | symbol, any> | null} the object last applied, compared by identity */ this.n = null; /** @type {Record<string, any>} the values last applied, by key */ this.p = {}; /** * The object last applied was a tracked object, or held a tracked value: * the same object can carry new values, so it is never skipped on identity. */ this.t = false; /** @type {Record<string, (() => void) | undefined> | null} listeners by event key */ this.l = null; /** @type {Record<string | symbol, any> | null} the ref props last applied */ this.r = null; /** @type {Record<string | symbol, Block | undefined> | null} the effects of those ref props */ this.b = null; } } /** What a nullish or primitive spread value applies. */ var EMPTY = {}; /** * Applies a spread of attributes to an element, diffing against what the * previous call applied: attributes, `class`, `style` objects, event * listeners and ref props. An object identical to the last one applied is * skipped outright, unless that object was a tracked object or held a * tracked value (its contents can change while its identity does not). * Called from a compiled render function with the state it returned before * (`undefined` the first time), which the render function stores. * @param {Element} element * @param {any} next * @param {SpreadState | undefined} state * @returns {SpreadState} */ export function spread(element, next, state) { if (next == null || (typeof next !== 'object' && typeof next !== 'function')) { next = EMPTY; } if (state === undefined) { return apply_spread(new SpreadState(element), next); } if (state.n === next && !state.t) { return state; } var prev = state.p; /** @type {Record<string, any>} */ var current = {}; /** @type {Record<string | symbol, any> | null} */ var refs = null; var tracked = TRACKED_OBJECT in next; var key; // A parent's ref prop travels through a spread on a symbol key. var symbols = get_own_property_symbols(next); for (var i = 0; i < symbols.length; i++) { var symbol = symbols[i]; if (symbol.description === REF_PROP) { refs = apply_ref_prop(state, symbol, next[symbol], refs); } } // Listeners and attributes that are gone, or now carry a ref prop, are // removed before anything is applied: removing `class` drops the scope // classes too, which `#class` then adds back. var listeners = state.l; if (listeners !== null) { for (key in listeners) { if (listeners[key] !== undefined && (!(key in next) || is_ref_prop(next[key]))) { /** @type {() => void} */ (listeners[key])(); listeners[key] = undefined; } } } for (key in prev) { if (key !== '#class' && (!(key in next) || is_ref_prop(next[key]))) { set_attribute_helper(element, key, null, state); } } for (key in next) { if (key === 'children') continue; var value = next[key]; if (typeof value === 'function' && is_ref_prop(value)) { refs = apply_ref_prop(state, key, value, refs); continue; } if (is_ripple_object(value)) { value = get(value); tracked = true; } current[key] = value; // The scope classes are added after every run: a `class` write may // have replaced them. if (key !== '#class' && key in prev && prev[key] === value) { continue; } set_attribute_helper(element, key, value, state); } if (state.r !== null) { release_ref_props(state, refs); } state.r = refs; state.p = current; state.n = next; state.t = tracked; return state; } /** * The first run of a spread: nothing to remove or compare, every value is * applied. * @param {SpreadState} state * @param {Record<string | symbol, any>} next * @returns {SpreadState} */ function apply_spread(state, next) { var element = state.e; /** @type {Record<string, any>} */ var current = {}; /** @type {Record<string | symbol, any> | null} */ var refs = null; var tracked = TRACKED_OBJECT in next; var symbols = get_own_property_symbols(next); for (var i = 0; i < symbols.length; i++) { var symbol = symbols[i]; if (symbol.description === REF_PROP) { refs = apply_ref_prop(state, symbol, next[symbol], refs); } } for (var key in next) { if (key === 'children') continue; var value = next[key]; if (typeof value === 'function' && is_ref_prop(value)) { refs = apply_ref_prop(state, key, value, refs); continue; } if (is_ripple_object(value)) { value = get(value); tracked = true; } current[key] = value; set_attribute_helper(element, key, value, state); } state.r = refs; state.p = current; state.n = next; state.t = tracked; return state; } /** * Keeps the effect of a ref prop the spread carried before, or creates one * for a ref prop that is new or changed. * @param {SpreadState} state * @param {string | symbol} key * @param {any} ref_fn * @param {Record<string | symbol, any> | null} refs the ref props of this run * @returns {Record<string | symbol, any>} */ function apply_ref_prop(state, key, ref_fn, refs) { if (refs === null) { refs = {}; } refs[key] = ref_fn; var previous = state.r; var blocks = (state.b ??= {}); var block = blocks[key]; if ( previous === null || !(key in previous) || previous[key] !== ref_fn || (block !== undefined && (block.f & DESTROYED) !== 0) ) { if (block !== undefined && (block.f & DESTROYED) === 0) { destroy_block(block); } blocks[key] = create_spread_ref_effect(state.e, ref_fn); } return refs; } /** * Destroys the effects of the ref props a spread no longer carries. * @param {SpreadState} state * @param {Record<string | symbol, any> | null} refs the ref props of this run */ function release_ref_props(state, refs) { var previous = /** @type {Record<string | symbol, any>} */ (state.r); var blocks = /** @type {Record<string | symbol, Block | undefined>} */ (state.b); /** @type {(string | symbol)[]} */ var keys = get_own_property_symbols(previous); for (var key in previous) { keys.push(key); } for (var i = 0; i < keys.length; i++) { var k = keys[i]; var block = blocks[k]; if ((refs === null || !(k in refs)) && block !== undefined) { destroy_block(block); blocks[k] = undefined; } } } /** * Releases what a spread owns when its element is dropped by something other * than its block (a dynamic element replaced by a tag change): its ref * effects, so refs receive null, and its listeners. * @param {SpreadState | undefined} state */ export function release_spread(state) { if (state === undefined) return; var blocks = state.b; if (blocks !== null) { release_ref_props(state, null); } var listeners = state.l; if (listeners !== null) { for (var key in listeners) { var remove = listeners[key]; if (remove !== undefined) { remove(); listeners[key] = undefined; } } } } /** * Keep spread refs in a branch block so ordinary spread updates do not destroy * and recreate the ref block before `spread` can compare the previous and * current ref values. * * @param {Element} element * @param {any} ref_fn * @returns {Block} */ function create_spread_ref_effect(element, ref_fn) { return branch(() => { ref(element, () => ref_fn); }); } /** * @typedef {{ e: Element; f: () => any; s: SpreadState | undefined }} RenderSpreadState */ /** * @param {RenderSpreadState} s */ function run_spread(s) { s.s = spread(s.e, s.f(), s.s); } /** * A render block that applies a spread of attributes to an element: for a * dynamic element, whose attributes are its props literal. Compiled * elements apply their spread from their own render function instead. * @param {Element} element * @param {() => any} fn * @param {number} [flags] * @returns {Block} */ export function render_spread(element, fn, flags = 0) { return block(RENDER_BLOCK | flags, run_spread, { e: element, f: fn, s: undefined }); }