UNPKG

ripple

Version:

Ripple is an elegant TypeScript UI framework

733 lines (624 loc) 18.8 kB
/** @import { Tracked } from '#client' */ /** @typedef {(v: unknown) => void} SetFunction */ /** @typedef {() => any} BindGetter */ /** @typedef {(v: unknown) => void} BindSetter */ /** @typedef {{getter: BindGetter, setter: BindSetter}} BindGetSet */ import { effect, render } from './blocks.js'; import { on } from './events.js'; import { get, set } from './runtime.js'; import { is_ripple_object } from './utils.js'; import { is_array } from '@tsrx/core/runtime/language-helpers'; /** * @param {string} name * @returns {TypeError} */ function not_tracked_type_error(name) { return new TypeError(`${name} argument is not a tracked object`); } /** * @param {string} name * @returns {TypeError} */ function not_set_function_type_error(name) { return new TypeError( `${name} second argument must be a set function when first argument is a get function`, ); } /** * @returns {TypeError} */ function invalid_select_multiple_value_error() { return new TypeError( 'Reactive bound value of a `<select multiple>` element should be an array, but it received a non-array value.', ); } /** * @param {string} name * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {BindGetSet} */ function get_bind_get_set(name, maybe_tracked, set_func) { if (typeof maybe_tracked === 'function') { if (typeof set_func !== 'function') { throw not_set_function_type_error(name); } return { getter: /** @type {BindGetter} */ (maybe_tracked), setter: set_func, }; } else { if (!is_ripple_object(maybe_tracked)) { throw not_tracked_type_error(name); } return { getter: () => get(/** @type {Tracked} */ (maybe_tracked)), setter: (value) => set(/** @type {Tracked} */ (maybe_tracked), value), }; } } /** * Resize observer singleton. * One listener per element only! * https://groups.google.com/a/chromium.org/g/blink-dev/c/z6ienONUb5A/m/F5-VcUZtBAAJ */ class ResizeObserverSingleton { /** */ #listeners = new WeakMap(); /** @type {ResizeObserver | undefined} */ #observer; /** @type {ResizeObserverOptions} */ #options; /** @static */ static entries = new WeakMap(); /** @param {ResizeObserverOptions} options */ constructor(options) { this.#options = options; } /** * @param {Element} element * @param {(entry: ResizeObserverEntry) => any} listener */ observe(element, listener) { var listeners = this.#listeners.get(element) || new Set(); listeners.add(listener); this.#listeners.set(element, listeners); this.#getObserver().observe(element, this.#options); return () => { var listeners = this.#listeners.get(element); listeners.delete(listener); if (listeners.size === 0) { this.#listeners.delete(element); /** @type {ResizeObserver} */ (this.#observer).unobserve(element); } }; } #getObserver() { return ( this.#observer ?? (this.#observer = new ResizeObserver( /** @param {any} entries */ (entries) => { for (var entry of entries) { ResizeObserverSingleton.entries.set(entry.target, entry); for (var listener of this.#listeners.get(entry.target) || []) { listener(entry); } } }, )) ); } } var resize_observer_content_box = /* @__PURE__ */ new ResizeObserverSingleton({ box: 'content-box', }); var resize_observer_border_box = /* @__PURE__ */ new ResizeObserverSingleton({ box: 'border-box', }); var resize_observer_device_pixel_content_box = /* @__PURE__ */ new ResizeObserverSingleton({ box: 'device-pixel-content-box', }); /** * @param {string} value */ function to_number(value) { return value === '' ? null : +value; } /** * @param {HTMLInputElement} input */ function is_numberlike_input(input) { var type = input.type; return type === 'number' || type === 'range'; } /** @param {HTMLOptionElement} option */ function get_option_value(option) { if ('__value' in option) { return option.__value; } return option.value; } /** * Selects the correct option(s) (depending on whether this is a multiple select) * @template V * @param {HTMLSelectElement} select * @param {V} value * @param {boolean} mounting */ function select_option(select, value, mounting = false) { if (select.multiple) { // If value is null or undefined, keep the selection as is if (value == undefined) { return; } // If not an array, warn and keep the selection as is if (!is_array(value)) { throw invalid_select_multiple_value_error(); } // Otherwise, update the selection for (var option of select.options) { option.selected = /** @type {string[]} */ (value).includes( /** @type {string} */ (get_option_value(option)), ); } return; } for (option of select.options) { var option_value = get_option_value(option); if (option_value === value) { option.selected = true; return; } } if (!mounting || value !== undefined) { select.selectedIndex = -1; // no option should be selected } } /** @type {MutationObserver | undefined} */ var select_mutation_observer; /** @type {Set<HTMLSelectElement> | undefined} */ var observed_selects; var select_observer_options = { childList: true, subtree: true, attributes: true, attributeFilter: ['value'], }; /** * @param {MutationRecord[]} entries * @returns {void} */ function process_select_mutation_entries(entries) { var selects = new Set(); for (const entry of entries) { const target = /** @type {HTMLElement} */ (entry.target); const select = /** @type {HTMLSelectElement | null} */ ( target.nodeName === 'SELECT' ? target : target.closest('select') ); if (!select || selects.has(select)) { continue; } if (observed_selects?.has(select)) { selects.add(select); select_option(select, select.__value); } } } /** * @param {HTMLSelectElement} select * @returns {void} */ function observe_select(select) { select_mutation_observer ??= new MutationObserver((entries) => { process_select_mutation_entries(entries); }); observed_selects ??= new Set(); observed_selects.add(select); select_mutation_observer.observe(select, select_observer_options); } /** * @param {HTMLSelectElement} select * @returns {void} */ function unobserve_select(select) { if (!observed_selects?.delete(select)) { return; } if (select_mutation_observer) { process_select_mutation_entries(select_mutation_observer.takeRecords()); } select_mutation_observer?.disconnect(); for (const current_select of observed_selects) { select_mutation_observer?.observe(current_select, select_observer_options); } } /** * Re-applies the current bound selection when option children change after mount. * @param {HTMLSelectElement} select * @returns {() => void} */ function init_select(select) { observe_select(select); return () => { unobserve_select(select); }; } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLInputElement | HTMLSelectElement) => void} */ export function bindValue(maybe_tracked, set_func = undefined) { var { getter, setter } = get_bind_get_set('bindValue()', maybe_tracked, set_func); return (node) => { /** @type {undefined | (() => void)} */ var clear_event; if (node.tagName === 'SELECT') { var select = /** @type {HTMLSelectElement} */ (node); var mounting = true; var clear_observer = init_select(select); clear_event = on(select, 'change', async () => { var query = ':checked'; /** @type {unknown} */ var value; if (select.multiple) { value = [].map.call(select.querySelectorAll(query), get_option_value); } else { /** @type {HTMLOptionElement | null} */ var selected_option = /** @type {HTMLOptionElement | null} */ (select.querySelector(query)) ?? // will fall back to first non-disabled option if no option is selected /** @type {HTMLOptionElement | null} */ ( select.querySelector('option:not([disabled])') ); value = selected_option && get_option_value(selected_option); } select.__value = value; setter(value); }); effect(() => { var value = getter(); select_option(select, value, mounting); select.__value = value; // Mounting and value undefined -> take selection from dom if (mounting && value === undefined) { if (select.multiple) { value = [].map.call(select.querySelectorAll(':checked'), get_option_value); select.__value = value; setter(value); } else { /** @type {HTMLOptionElement | null} */ var selected_option = /** @type {HTMLOptionElement | null} */ ( select.querySelector(':checked') ); if (selected_option !== null) { value = get_option_value(selected_option); select.__value = value; setter(value); } } } mounting = false; }); return () => { clear_event?.(); clear_observer(); }; } else { var input = /** @type {HTMLInputElement} */ (node); clear_event = on(input, 'input', () => { /** @type {any} */ var value = input.value; value = is_numberlike_input(input) ? to_number(value) : value; setter(value); const getter_value = getter(); // Check the getter to see if it's different from the input.value // The setter may have decided not to update its track value or update it to something else // We treat the getter as the source of truth since we cannot verify the change otherwise // If getter() !== input.value, we set the input value right away // the `render` block may be scheduled only if the tracked value has changed // but it will not do anything if getter() === input.value // The result is: the `render` block will ALWAYS exit early if the microtask // came from this event handler if (value !== getter_value) { var start = input.selectionStart; var end = input.selectionEnd; input.value = getter_value ?? ''; if (end !== null && start !== null) { end = Math.min(end, input.value.length); start = Math.min(start, end); input.selectionStart = start; input.selectionEnd = end; } } }); render(() => { var value = getter(); if (is_numberlike_input(input) && value === to_number(input.value)) { return; } if (input.type === 'date' && !value && !input.value) { return; } if (value !== input.value) { // this can only get here if the tracked value was changed directly, // and not via the input event input.value = value ?? ''; } }); return clear_event; } }; } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLInputElement) => void} */ export function bindChecked(maybe_tracked, set_func = undefined) { var { getter, setter } = get_bind_get_set('bindChecked()', maybe_tracked, set_func); return (input) => { var clear_event = on(input, 'change', () => { setter(input.checked); }); effect(() => { var value = getter(); input.checked = Boolean(value); }); return clear_event; }; } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLInputElement) => void} */ export function bindIndeterminate(maybe_tracked, set_func = undefined) { var { getter, setter } = get_bind_get_set('bindIndeterminate()', maybe_tracked, set_func); return (input) => { var clear_event = on(input, 'change', () => { setter(input.indeterminate); }); effect(() => { var value = getter(); input.indeterminate = Boolean(value); }); return clear_event; }; } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLInputElement) => void} */ export function bindGroup(maybe_tracked, set_func = undefined) { var { getter, setter } = get_bind_get_set('bindGroup()', maybe_tracked, set_func); return (input) => { var is_checkbox = input.getAttribute('type') === 'checkbox'; var clear_event = on(input, 'change', () => { var value = input.value; var result; if (is_checkbox) { /** @type {Array<any>} */ var list = getter() || []; if (input.checked) { if (!list.includes(value)) { result = [...list, value]; } else { result = list; } } else { result = list.filter((v) => v !== value); } } else { result = input.value; } setter(result); }); effect(() => { var value = getter(); if (is_checkbox) { value = value || []; input.checked = value.includes(input.value); } else { input.checked = value === input.value; } }); return clear_event; }; } /** * @param {unknown} maybe_tracked * @param {'clientWidth' | 'clientHeight' | 'offsetWidth' | 'offsetHeight'} type * @param {SetFunction | undefined} set_func */ function bind_element_size(maybe_tracked, type, set_func = undefined) { var { setter } = get_bind_get_set( `bind${type.charAt(0).toUpperCase() + type.slice(1)}()`, maybe_tracked, set_func, ); return (/** @type {HTMLElement} */ element) => { var unsubscribe = resize_observer_border_box.observe(element, () => setter(element[type])); effect(() => { setter(element[type]); return unsubscribe; }); }; } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLElement) => void} */ export function bindClientWidth(maybe_tracked, set_func = undefined) { return bind_element_size(maybe_tracked, 'clientWidth', set_func); } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLElement) => void} */ export function bindClientHeight(maybe_tracked, set_func = undefined) { return bind_element_size(maybe_tracked, 'clientHeight', set_func); } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLElement) => void} */ export function bindOffsetWidth(maybe_tracked, set_func = undefined) { return bind_element_size(maybe_tracked, 'offsetWidth', set_func); } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLElement) => void} */ export function bindOffsetHeight(maybe_tracked, set_func = undefined) { return bind_element_size(maybe_tracked, 'offsetHeight', set_func); } /** * @param {unknown} maybe_tracked * @param {'contentRect' | 'contentBoxSize' | 'borderBoxSize' | 'devicePixelContentBoxSize'} type * @param {SetFunction | undefined} set_func */ function bind_element_rect(maybe_tracked, type, set_func = undefined) { var { setter } = get_bind_get_set( `bind${type.charAt(0).toUpperCase() + type.slice(1)}()`, maybe_tracked, set_func, ); var observer = type === 'contentRect' || type === 'contentBoxSize' ? resize_observer_content_box : type === 'borderBoxSize' ? resize_observer_border_box : resize_observer_device_pixel_content_box; return (/** @type {HTMLElement} */ element) => { var unsubscribe = observer.observe( element, /** @param {any} entry */ (entry) => setter(entry[type]), ); effect(() => unsubscribe); }; } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLElement) => void} */ export function bindContentRect(maybe_tracked, set_func = undefined) { return bind_element_rect(maybe_tracked, 'contentRect', set_func); } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLElement) => void} */ export function bindContentBoxSize(maybe_tracked, set_func = undefined) { return bind_element_rect(maybe_tracked, 'contentBoxSize', set_func); } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLElement) => void} */ export function bindBorderBoxSize(maybe_tracked, set_func = undefined) { return bind_element_rect(maybe_tracked, 'borderBoxSize', set_func); } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLElement) => void} */ export function bindDevicePixelContentBoxSize(maybe_tracked, set_func = undefined) { return bind_element_rect(maybe_tracked, 'devicePixelContentBoxSize', set_func); } /** * @param {unknown} maybe_tracked * @param {'innerHTML' | 'innerText' | 'textContent'} property * @param {SetFunction | undefined} set_func * @returns {(node: HTMLElement) => void} */ export function bind_content_editable(maybe_tracked, property, set_func = undefined) { var { getter, setter } = get_bind_get_set( `bind${property.charAt(0).toUpperCase() + property.slice(1)}()`, maybe_tracked, set_func, ); return (element) => { var clear_event = on(element, 'input', () => { setter(element[property]); }); render(() => { var value = getter(); if (element[property] !== value) { if (value == null) { var non_null_value = element[property]; setter(non_null_value); } else { element[property] = value + ''; } } }); return clear_event; }; } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLElement) => void} */ export function bindInnerHTML(maybe_tracked, set_func = undefined) { return bind_content_editable(maybe_tracked, 'innerHTML', set_func); } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLElement) => void} */ export function bindInnerText(maybe_tracked, set_func = undefined) { return bind_content_editable(maybe_tracked, 'innerText', set_func); } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLElement) => void} */ export function bindTextContent(maybe_tracked, set_func = undefined) { return bind_content_editable(maybe_tracked, 'textContent', set_func); } /** * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLInputElement) => void} */ export function bindFiles(maybe_tracked, set_func = undefined) { var { getter, setter } = get_bind_get_set('bindFiles()', maybe_tracked, set_func); return (input) => { var clear_event = on(input, 'change', () => { setter(input.files); }); effect(() => { var value = getter(); if (value !== input.files && value instanceof FileList) { input.files = value; } }); return clear_event; }; } /** * Syntactic sugar for binding a HTMLElement with {ref fn} * @param {unknown} maybe_tracked * @param {SetFunction | undefined} set_func * @returns {(node: HTMLElement) => void} */ export function bindNode(maybe_tracked, set_func = undefined) { var { setter } = get_bind_get_set('bindNode()', maybe_tracked, set_func); /** @param {HTMLElement} node */ return (node) => { setter(node); }; }