UNPKG

rimmel

Version:

A Streams-Oriented UI library for the Rx.Observable Universe

1,189 lines (1,140 loc) 99.6 kB
var rml = (function (exports, rxjs) { 'use strict'; self.RMLREF = ''; const REF_TAG = 'RMLREF+'; // custom attribute and corresponding selector to find just-mounted elements // that need any data binding const RESOLVE_ATTRIBUTE = 'resolve'; // keep lowercase for SVG const RESOLVE_SELECTOR = `[${RESOLVE_ATTRIBUTE}]`; // An equivalent of the "debugger;" JavaScript expression, for templates const RML_DEBUG = 'rml:debugger'; // Special, non-printable Unicode characters to wrap interactive text nodes // letting Rimmel know they'll need to be rendered as Text Nodes in the DOM, for updates const INTERACTIVE_NODE_START = '\u200B'; const INTERACTIVE_NODE_END = '\u200C'; // FIXME: can't use this const SINK_TAG = 'sink'; // Use the new native Web Platform Observables instead of addEventListener when available var USE_DOM_OBSERVABLES = false; const set_USE_DOM_OBSERVABLES = ((x) => USE_DOM_OBSERVABLES = x); const SymbolObservature = Symbol.for('observature'); // export const configure = () => { // return rml // } const waitingElementHanlders = new Map(); const subscriptions = new Map(); // FIXME: add a unique prefix to prevent collisions with different dupes of the library running in the same context/app const state = { refCount: 0, }; const isObserver = (x) => // FIXME: it should actually be x?.next || x?.error || x?.complete // or a function !!x?.next; const isObservable = (x) => !!x?.subscribe; const isPromise = (x) => !!x?.then; const isFuture = (x) => isPromise(x) || isObservable(x); const isFunction = (fn) => fn instanceof Function; /** * Checks whether the provided template expression is an Observer (Rx Subscribable) * @param expression a template expression to check * @returns is ObserverSourceHandler */ const isObserverSource = (expression) => isFunction(expression?.next); /** * A data source that connects to and feeds an Observer stream or RxJS Subject * * @param handler an Observer stream or RxJS Subject * @returns */ const ObserverSource = (handler) => handler.next.bind(handler); // export type Empty = MaybeFuture<undefined | null | ''>; // export type BindingConfigurationType<T> = T extends SinkBindingConfiguration<infer Q> ? SinkBindingConfiguration<Q> : SourceBindingConfiguration<infer Z>; const isSinkBindingConfiguration = (b) => b.type == 'sink'; const isSourceBindingConfiguration = (b) => b.type == 'source'; const isSourceExpression = (e) => isFunction(e) || isObserverSource(e); const isPresentSinkAttributeValue = (value) => !isFuture(value); const isFutureSinkAttributeValue = (value) => isFuture(value); /** * Get the value of an <input> element matching its type: * boolean, number, date or string, * or innerText if it's any other [contenteditable] element **/ const autoValue = (input) => input.type == 'checkbox' ? input.checked : input.type == "number" ? input.valueAsNumber : input.type == "date" ? input.valueAsDate : input.tagName == 'INPUT' ? input.value : input.tagName == 'SELECT' ? input.value : input.innerText; /** * @deprecated don't use **/ const isObjectSource = (expression) => Array.isArray(expression) && expression.length == 2; /** * A data source that updates an object's property from an &lt;input&gt; element when * a certain event occurs * @param key an ['property', object] or [index, array] pair to update * @returns A data source * @example <input oninput="${[obj, 'property']}"> * @example <input oninput="${ObjectSource('property', obj)}"> * @example <input oninput="${ObjectSource(4, arr)}"> */ const ObjectSource = (key, targetObject) => { const handler = ((targetObject, e) => { const t = e.target; targetObject[key] = autoValue(t); }); return (targetObject ? handler.bind(null, targetObject) : (t2) => handler.bind(null, t2)); }; /** * An Event Adapter that uses an event's underlying &lt;input&gt; element * to updates an object's property or an array item. * @param property A property to update in the given object or an index to update in the given array * @param object The object or array to update * @returns An event handler */ const Update = (property, object) => ObjectSource(property, object); const asObjectSource = ObjectSource; const AsObjectSource = ObjectSource; /** * Checks whether the provided template expression is an EventListenerObject * @param expression a template expression to check * @returns is EventListenerObject */ const isEventListenerObjectSource = (expression) => expression?.handleEvent; /** * A data source that connects to and feeds an EventListenerObject via its handleEvent method * @param handler an Observer stream or RxJS Subject * @returns */ const EventListenerObjectSource = (handler) => handler.handleEvent.bind(handler); // TODO: we used to just chain handler.handleEvent?.bind(handler) ?? .... rather than these type guards. Can we still, somehow? /** * Convert a function, Observer or Observature to a listener function. * @param expression RMLTemplateExpression * @returns function | null a callable event listener function */ const toListener = (expression) => // FIXME: too similar to callable. Merge them isObserverSource(expression) ? ObserverSource(expression) : isFunction(expression) ? expression : isEventListenerObjectSource(expression) ? EventListenerObjectSource(expression) : isObjectSource(expression) ? ObjectSource(...expression) : null // We allow it to be empty. If so, ignore, and don't connect any source. Perhaps add a warning in debug mode? ; /** * Return the "callable" part of an entity: * - the next method of an Observer * - the handleEvent method of an EventListenerObject * - the function itself, if it's a function */ const callable = (x) => x.next ? x.next.bind(x) : x.handleEvent ? x.handleEvent.bind(x) : x; // FIXME: remove, use subscribe below instead const asap = (fn, arg) => { arg?.subscribe?.(fn) ?? arg?.then?.(fn.next?.bind(fn) ?? fn) ?? fn(arg); }; /** * Connect an event source to a sink through any compatible interface on any optionally specified scheduler * @param node The node on which the binding is set * @param source A Promise, Observable or EventEmitter * @param next A "next" or "then" handler on the sink side * @param error? An error handler on the sink side * @param complete? a finalisation function */ const subscribe = (node, source, next, error, complete, scheduler) => { // TODO: make this a plugin, in case people don't use handleEvent... const flattenedNext = toListener(next); const task = scheduler?.(node, flattenedNext) ?? flattenedNext; if (isObservable(source)) { // TODO: should we handle promise cancellations (cancellable promises?) too? const subscription = source.subscribe({ next: task, error, complete, }); subscriptions.get(node)?.push(subscription) ?? subscriptions.set(node, [subscription]); return subscription; } else if (isPromise(source)) { source.then(task, error).finally(complete); } else { // TODO: should we handle function cancellations (removeEventListener) too? task(source); } }; // TODO: what should happen (if anything at all?) when an observable terminates? // Remove the node? // Emit empty? //const terminationSink = (node) => node.remove(); // FIXME: This might not even be a Sink... /** * Experimental sink for terminating observables */ const terminationHandler = () => { // console.debug('Rimmel: NOOP termination sink called', data); }; // @ts-nocheck const CreateObservature = (initial) => { let sources = []; let subscribers = []; const operators = []; const output = new Observable((observer) => { subscribers.push(observer); return { unsubscribe: () => { subscribers = subscribers.filter(sub => sub !== observer); } }; }); const applyPipeline = (source) => { const pipeline = operators.reduce((obs, [prop, args]) => obs[prop](...args), source); return pipeline.subscribe({ next: (val) => subscribers.forEach(sub => sub.next?.(val) ?? sub(val)), error: (error) => subscribers.forEach(sub => sub.error?.(error) ?? sub(error)), complete: () => subscribers.forEach(sub => sub.complete?.()) }); }; return new Proxy(output, { get(target, prop) { switch (prop) { case 'value': return initial; case Symbol.for('observable'): case '@@observable': return function () { return this; }; case '@@Observature': case 'Observature': case SymbolObservature: return true; case 'addSource': return (_source) => { sources.push(_source); return target; }; case 'type': return undefined; case 'next': return (value) => applyPipeline(Observable.from([value])); case 'error': return (error) => subscribers.forEach(sub => sub.error?.(error) ?? sub(error)); case 'complete': return () => subscribers.forEach(sub => sub.complete?.()); case 'subscribe': return (_observer) => { subscribers.push(_observer); const starter = Observable.merge(...[].concat(sources, initial ? Observable.from([].concat(initial ?? [])) : [])); const subscription = applyPipeline(starter); if (initial !== undefined) { subscribers.forEach(sub => sub.next?.(initial)); } return subscription; }; default: if (Observable.prototype.hasOwnProperty(prop)) { return function (...args) { // FIXME: this should return a new Observature // (or a separate pipeline rather than modifying the original?) // we still want to keep the same sources operators.push([prop, args]); return this; }; } return target[prop]; } } }); }; class Observature { constructor(initial) { return CreateObservature(initial); } } const isObservature = (x) => x?.Observature || x[SymbolObservature]; const isEventListenerObject = (l) => !!l?.handleEvent; const addListener = (node, eventName, listener, options) => { // We also force-add an event listener if we're inside a ShadowRoot (do we really need to?), as events inside web components don't seem to fire otherwise if (USE_DOM_OBSERVABLES && node.when) { // Explicitly excluding the isEventListenerObject as Domenic doesn't want .when() to support it if (!isEventListenerObject(listener)) { const source = node.when(eventName, options); if (isObservature(listener)) { listener.addSource(source); } else { // TODO: Add AbortController source.subscribe(listener); } } } else { node.addEventListener(eventName, toListener(listener), options); // #REF49993849837451 // const listenerRef = [eventName, sourceBindingConfiguration.listener, sourceBindingConfiguration.options]; // node.addEventListener(...listenerRef); // listeners.get(node)?.push?.(listenerRef) ?? listeners.set(node, [listenerRef]); } if (/^(?:rml:)?mount/.test(eventName)) { // Will this need to bubble up? (probably no) setTimeout(() => node.dispatchEvent(new Event(eventName))); } }; const AUTOREMOVE_LISTENERS_DELAY = 100; // Cleanup event listeners after this much time const elementNodes = (n) => n.nodeType == 1; const errorHandler = console.error; const Rimmel_Bind_Subtree = (node) => { // Data-to-be-bound text nodes in an element (<div>${thing1} ${thing2}</div>); const intermediateInteractiveNodes = []; const hasInteractiveTextNodes = [...node.childNodes].some(n => { return n.nodeType == 3 && n.nodeValue?.includes(INTERACTIVE_NODE_START); }); if (hasInteractiveTextNodes) { // Bind interactive "text nodes" // TODO: shall we use some ad-hoc container elements, instead? <text-wrapper> const nodes = []; for (const n of node.childNodes) { if (n.nodeType == 3) { const nodeValue = n.nodeValue; const interactiveRE = new RegExp(`[${INTERACTIVE_NODE_START}${INTERACTIVE_NODE_END}]`); const interleaved = nodeValue.split(interactiveRE); const il = interleaved.length; for (var i = 0; i < il; i += 2) { const txt = interleaved[i]; nodes.push(txt); const value = interleaved[i + 1]; if (value != undefined) { const tn = document.createTextNode(value); // or "value"? intermediateInteractiveNodes.push(tn); // do we have an initial value we can add straight away? nodes.push(tn); } } } else { nodes.push(n); } } node.innerHTML = ''; node.append(...nodes); } const bindingRef = node.getAttribute(RESOLVE_ATTRIBUTE); (waitingElementHanlders.get(bindingRef) ?? []).forEach(function Rimmel_Bind_Element(bindingConfiguration) { const debugThisNode = node.hasAttribute(RML_DEBUG); // #IFDEF ENABLE_RML_DEBUGGER if (debugThisNode) { /* Stopped at data binding */ debugger; } // #ENDIF ENABLE_RML_DEBUGGER if (isSinkBindingConfiguration(bindingConfiguration)) { // DATA SINKS // TODO: bindingConfiguration.sinkParams may itself be a promise or an observable, so need to subscribe to it const targetNode = intermediateInteractiveNodes.shift() ?? node; const { sink, t } = bindingConfiguration; const sinkFn = sink(targetNode, bindingConfiguration.params); // A pre-sink step that can show the above sinkFn in a stack trace for debugging const loggingSinkFn = (...data) => { console.groupCollapsed('RML: Sinking', t, data); console.log(bindingConfiguration); console.trace('Stack Trace (from Source to Sink), data=', data); sinkFn(...data); console.groupEnd(); }; // #IFDEF ENABLE_RML_DEBUGGER // This is the actual sink that will be bound to a source const sinkFn2 = debugThisNode ? loggingSinkFn : sinkFn; if (debugThisNode) { console.groupCollapsed('RML: Binding', t, targetNode); console.dir(targetNode); console.debug('Node: %o', targetNode); console.debug('Conf: %o', bindingConfiguration); console.debug('Sink: %o', sinkFn2); console.groupEnd(); } // #ELSE // const sinkFn2 = sinkFn; // #ENDIF ENABLE_RML_DEBUGGER const sourceStream = bindingConfiguration.source; subscribe(targetNode, sourceStream, sinkFn2, bindingConfiguration.error ?? errorHandler, bindingConfiguration.termination ?? terminationHandler, bindingConfiguration.scheduler); } else { // EVENT SOURCES const sourceBindingConfiguration = bindingConfiguration; const { eventName } = sourceBindingConfiguration; // console.log('addListner', node, eventName, sourceBindingConfiguration.listener, sourceBindingConfiguration.options); addListener(node, eventName, sourceBindingConfiguration.listener, sourceBindingConfiguration.options); } }); node.removeAttribute(RESOLVE_ATTRIBUTE); waitingElementHanlders.delete(bindingRef); }; const removeListeners = (node) => { if (document.contains(node)) { // Don't remove listeners if the node has just been moved across (so it's back in the DOM) return; } [...node.children] .forEach(node => removeListeners(node)); // TODO: add AbortController support for cancellable promises? subscriptions.get(node)?.forEach(l => { // HACK: — destination is not a supported API for Subscription... // l?.destination?.complete(); // do we need this, BTW? // console.debug('Rimmel: Unsubscribing', node, l); // FIXME: DOM Observables don't have unsubscribe => Use an AbortController in addListener.ts // N.B.: unsubscribe is RxJS-specific, but the below still works. l?.unsubscribe?.(); }); subscriptions.delete(node); // #REF49993849837451 Just leaving this around, but there's no need to manually // remove listeners. // DevTools might suggest otherwise, but if you see unreleased instances of // EventListener, then HE is holding on to them (Heisenbug), not Rimmel. // listeners.get(node)?.forEach(ref => node.removeEventListener(...ref)); // listeners.delete(node); }; /** * Main callback triggered when an element is added to the DOM * Here is where we start the data binding process */ const Rimmel_Mount = (mutationsList, observer) => { const childList = mutationsList .filter(m => m.type === 'childList'); // TODO: performance - use document.createTreeWalker const addedNodes = childList .flatMap(m => ([...m.addedNodes])) // .values() for an iterator, according to TS .filter(elementNodes); addedNodes .flatMap(node => [node].concat(...(node.querySelectorAll(RESOLVE_SELECTOR)))) .forEach(Rimmel_Bind_Subtree); // TODO: performance - use document.createTreeWalker const removedNodes = childList .flatMap(m => ([...m.removedNodes])) // .values() for an iterator, according to TS .filter(elementNodes); // TODO: switch when ready // requestIdleCallback(() => removedNodes.forEach(removeListeners)); setTimeout(() => removedNodes.forEach(removeListeners), AUTOREMOVE_LISTENERS_DELAY); }; const isNumericFieldElement = (e) => e instanceof HTMLInputElement && (e.type === 'number' || e.type === 'range'); const isRMLEventListener = (name, arg) => /^(?:rml:)?on/.test(name); // TODO: use a Symbol instead of .sink? const isSink = (x) => !!(x?.sink); const isSource = (x) => x.type == 'source'; // List of HTML boolean attributes // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML // These enable a certain functionality by their mere presence in a tag. // E.G.: <input disabled="false"> is still disabled, which is unintuitive. // <input disabled="${stream}"> should really set or unset the disabled state depending on the stream's last emitted value! // If you don't like this behaviour, we have a "rml" prefixed set of such attributes, that actually behave like booleans // TODO: review, see if we can convert to a type... don't want all these in the bundles const BOOLEAN_ATTRIBUTES = new Set([ 'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default', 'defer', 'disabled', 'formnovalidate', 'hidden', 'ismap', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate', 'open', 'readonly', 'required', 'reversed', 'selected' ]); /** * Create an "input pipe" by prepending operators to the input of an Observer, Subject, BehaviorSubject, or plain subscriber function. * This works the opposite of RxJS's pipe(), which works on the output of an Observable. **/ const pipeIn = (target, ...pipeline) => { const source = new rxjs.Subject(); source .pipe(...pipeline) .subscribe(target); // FIXME: will we need to unsubscribe? Then store a reference for unsubscription // TODO: can we/should we delay subscription until mounted? Could miss the first events otherwise // TODO: check if a Subject is needed, or if we can connect directly to the target (e.g. w/ Observature.addSource) return source; }; /** * Create an "input pipe" by prepending operators to an Observer or a Subject * * @remarks This works the opposite of the `pipe()` function in RxJS, which * transforms the output of an observable whilst this transforms the input. * * You normally use an input pipe to create Event Adapters. * * @template I the input type of the returned stream (the event adapter) * @template O the output type of the returned stream (= the input type of the actual target stream) * @example const MyUsefulEventAdapter = inputPipe(...pipeline); * const template = rml` * <input onkeypress="${MyUsefulEventAdapter(targetObserver)}"> * `; **/ const inputPipe = (...pipeline) => (target) => pipeIn(target, ...pipeline); const feed = pipeIn; const feedIn = pipeIn; const reversePipe = inputPipe; // TBC const source = (...reversePipeline) => pipeIn(reversePipeline.pop(), ...reversePipeline); const sink = (source, ...pipeline) => source.pipe(...pipeline); /** * Currying "in" for input stream operators * Create a curried observer stream from a given target * by applying the specified input pipeline to it **/ const curry = (op, destination) => destination ? pipeIn(destination, op) : inputPipe(op); /** * Forces an event listener to be active (e.g.: scroll, touchstart) * * @experimental * @example target.innerHTML = rml` * <div onmouseover="${Active( handler )}">...</div> * `; */ const Active = (listener) => ({ type: 'source', listener: callable(listener), options: { passive: false } }); /** * Forces an event listener to be passive * * @experimental * @example target.innerHTML = rml` * <div onmouseover="${Passive( handler )}">...</div> * `; */ const Passive = (listener) => ({ type: 'source', listener: callable(listener), options: { passive: true } }); /** * An Event Source emitting the "[event.clientX, event.clientY]" mouse coordinates * @param querySelector A query selector to select nodes from the underlying element's subtree * @param target A handler function or observer to send events to */ const All = (querySelector, target) => pipeIn(target, rxjs.map((e) => [].concat(querySelector) .flatMap(querySelector => [...e.currentTarget.querySelectorAll(querySelector)]))); const qsa = (qs) => rxjs.map((e) => { const t = e.currentTarget; return [].concat(qs).flatMap(qs => [...t.querySelectorAll(qs)]); }); const DataMappers = { text: (x) => x, number: (x) => Number(x), date: (x) => new Date(x), }; const ElementMappers = { text: (e) => e.value, number: (e) => e.valueAsNumber, date: (e) => e.valueAsDate, checkbox: (e) => e.checked, radio: (e) => e.checked && (DataMappers[e.dataset.type]?.(e.value) ?? e.value) || undefined, 'select-one': (e) => DataMappers[(e.options[e.selectedIndex].dataset.type ?? e.dataset.type)]?.(e.value) ?? e.value, 'select-multiple': (e) => [...e.options].filter(o => o.selected).map(o => (DataMappers[o.dataset.type ?? e.dataset.type] ?? DataMappers.text)(o.value)), }; const resolve = (e) => (ElementMappers[(e.type ?? e.dataset.type)] ?? ElementMappers.text)(e); /** * An Event Operator emitting a typed FormData object from the underlying &lt;form&gt; element instead of a regular DOM Event object * Field types are taken from their respective data-type attribute or string if unset * @returns OperatorFunction<Event, FormData> * @example <form onsubmit="${source(isValid, form, stream)}"> ... </form> **/ const autoForm = rxjs.map((e) => Object.fromEntries([...e.currentTarget.elements] .map((e) => [e.name ?? e.id, resolve(e)]) .filter(([k, v]) => k !== '' && v !== undefined))); /** * An Event Adapter emitting a FormData object from the underlying form element instead of a regular DOM Event object * @returns EventSource<string> * @example <form action="dialog" onsubmit="${AsTypedForm(stream)}"> ... </form> * @example <form action="dialog" onsubmit="${AsTypedForm(handlerFn)}"> ... </form> **/ const AutoForm = inputPipe(autoForm); /** * An Event Source Operator emitting the checked state of the underlying checkbox element instead of a regular DOM Event object * @returns OperatorFunction<Event, boolean> */ const checkedState = rxjs.map((e) => e.target.checked); /** * An Event Source emitting the checked state of the underlying checkbox element instead of a regular DOM Event object * @returns EventSource<boolean> */ const CheckedState = inputPipe(checkedState); /** * An Event Operator that "cuts" and emits the value of the underlying &lt;input&gt; element into a target observer * * This operator has side effects, as it will directly modify the underlying element * @category Event Adapter Operators * @template T the type of the target element * @template I the type of Event sourced from the underlying element * @template O the data type emitted into the target stream * @returns OperatorFunction<Event, string> * * For simple, one-step input pipelines, see the {@link Cut | Cut (uppercase)} Event Adapter * * ## Examples * * ### UpperCut * * Create a custom Event Operator that feeds a stream with the uppercase content of a textbox, when hitting Enter on it * * ```ts * import { Subject, filter, map } from 'rxjs'; * import { rml, inputPipe, cut } from 'rimmel'; * * const onEnter = filter((e: KeyboardEvent) => e.key == 'Enter'); * const toUpperCase = map((s: string) => s.toUpperCase()); * const UpperCut = inputPipe(onEnter, cut, toUpperCase); * * const Component = () => { * const stream = new Subject<string>(); * * return rml` * <input type="text" onkeypress="${UpperCut(stream)}" autofocus> * [ <span>${stream}</span> ] * `; * }; * ``` */ const cut = rxjs.map((e) => { const t = e.target; const v = autoValue(t); t.value = ''; // TODO: t.innerText = '' for contenteditable items? return v; }); /** * An Event Adapter that "cuts" and emits the value of the underlying &lt;input&gt; element into a target observer * * @category Event Adapter Functions * @param target A handler function or Observer to feed events into * @returns EventSource<string> * * For advanced, multi-step input pipelines, see the {@link cut | cut (lowercase)} Operator * * ## Examples * * ### Feed a List * * Feed an [observable list](https://github.com/reactivehtml/observable-types ) with the uppercase content of a textbox, when hitting Enter on it * * ```ts * import { Collection } from 'observable-types'; * import { Subject, filter, map } from 'rxjs'; * import { rml, Cut } from 'rimmel'; * * const Component = () => { * const list = Collection(['item1', 'item2', 'item3']); * * return rml` * <ul>${list}</ul> * Add new: <input type="text" onkeypress="${Cut(stream.push)}" autofocus> * `; * }; * ``` */ const Cut = inputPipe(cut); /** * An Event Adapter Operator emitting any dataset value from the underlying element instead of a regular DOM Event object * @category Event Adapter Operators * @returns OperatorFunction<Event, string> * @example <button data-foo="bar" onclick="${source(dataset('foo'), stream)}"> ... </button> **/ const dataset = (key) => rxjs.map((e) => (e.target.dataset[key])); /** * An Event Source emitting any dataset value from the underlying element instead of a regular DOM Event object * @category Event Adapter Functions * @param key The key of the dataset item to retrieve * @param source A handler function or Observer to feed events into * @returns EventSource<string> * @example <button data-foo="bar" onclick="${Dataset('foo', stream)}"> ... </button> * @example <button data-foo="bar" onclick="${Dataset('foo', handlerFn)}"> ... </button> **/ const Dataset = (key, source) => curry(dataset(key), source); /** * An Event Source Operator emitting the full dataset object from the underlying element instead of a regular DOM Event object * @returns OperatorFunction<Event, DOMStringMap> * @example <button data-foo="bar" data-baz="bat" onclick="${source(datasetObject, stream)}"> ... </button> **/ const datasetObject = rxjs.map((e) => (e.target.dataset)); /** * An Event Source emitting the full dataset object from the underlying element instead of a regular DOM Event object * @category Event Adapter Functions * @param source A handler function or Observer to feed events into * @returns EventSource<string> * @example <button data-foo="bar" data-baz="bat" onclick="${DatasetObject(stream)}"> ... </button> * @example <button data-foo="bar" data-baz="bat" onclick="${DatasetObject(handlerFn)}"> ... </button> **/ const DatasetObject = (source) => curry(datasetObject, source); /** * An Event Operator emitting a numerical dataset value from the underlying element instead of a regular DOM Event object * @returns OperatorFunction<Event, number> * @example <button data-foo="123" onclick="${source(numberset('foo'), isEven, stream)}"> ... </button> **/ const numberset = (key) => rxjs.map((e) => Number(e.target.dataset[key])); /** * An Event Source emitting a numerical dataset value from the underlying element instead of a regular DOM Event object * @returns EventSource<number> * @example <button data-foo="123" onclick="${Numberset('foo', stream)}"> ... </button> * @example <button data-foo="123" onclick="${Numberset('foo', handlerFn)}"> ... </button> **/ const Numberset = (key) => inputPipe(numberset(key)); /** * An Event Operator that emits the value of the underlying &lt;input&gt; element * @returns EventSource<string> **/ const eventData = rxjs.map((e) => e.data); /** * An Event Adapter that emits the value of the underlying &lt;input&gt; element * @returns EventSource<string> **/ const EventData = inputPipe(eventData); const EventTarget = inputPipe(rxjs.map(e => e.target)); /** * An Event Operator emitting a FormData object from the underlying form element instead of a regular DOM Event object * @returns OperatorFunction<Event, FormData> * @example <form onsubmit="${source(isValid, form, stream)}"> ... </form> **/ const form = rxjs.map((e) => { if (e.type == 'submit' && e.target.method != 'dialog') { // This is contentious. Should we or sohuld we not call preventDefault() on the event here? // If we're using this in a dialog, we probably want it to close // On the other hand, if we're using this in a regular form, we probably want to submit it later e.preventDefault(); } return Object.fromEntries(new FormData(e.currentTarget) // Checkboxes unintuitively emit "on" vs "null" // FIXME: this should become a pipeline plugin, so people can choose their preferred behaviour? // .map(([k, v]) => [k, v == 'on' ? true : v == 'null' ? false]) ); }); /** * An Event Adapter emitting the underlying form's key-values * @effect calls preventDefault() on the event * @unstable might need to review the preventDefault() behaviour * @unstable might need to review the checkbox behaviour (on/off vs true/false) * @returns EventSource<string> * @example `<form action="dialog" onsubmit="${Form(stream)}"> ... </form>` * @example `<form action="dialog" onsubmit="${Form(handlerFn)}"> ... </form>` * * ## Examples * ### Submit a form into a stream * * No need to call preventDefault() on the event, as this is done automatically * ```ts * import { rml } from 'rimmel'; * * const Component = () => { * const stream = new Subject<FormData>(); * * return rml` * <form onsubmit="${stream}"> * <input type="text" name="name"> * <input type="email" name="email"> * </form> * `; * } * ``` * * ### Submit a dialog form into a stream * ```ts * import { rml } from 'rimmel'; * * const Component = () => { * const stream = new Subject<FormData>(); * * return rml` * <form onsubmit="${stream}"> * <input type="text" name="name"> * <input type="email" name="email"> * </form> * `; * } * ``` **/ const Form = inputPipe(form); const asFormData = form; const AsFormData = Form; // import type { char } from '../types/basic'; /** * An Event Operator emitting event.key instead of any KeyboardEvent object * @returns OperatorFunction<KeyboardEvent, string> * * For one-step input pipelines, see the {@link Key | Key (uppercase)} Event Adapter * * ## Examples * * ### Collect distinct vowels typed in a text box * * The following illustrates how to get any key presses from the text box * and only pass the actual character through to the main stream * * ```ts * import { Subject, distinct, filter, scan } from 'rxjs'; * import { rml, source, key } from 'rimmel'; * * const isVowel = filter(c => 'aeiou'.includes(c)); * * const Component = () => { * const vowels = new Subject<string>().pipe( * distinct(), * scan((a, b) => a.concat(b)), * ); * * return rml` * Unique vowels pressed: "<span>${vowels}"</span>" * * <input onkeypress="${source(key, isVowel, vowels)}> * `; * } * ``` */ const key = rxjs.map((e) => e.key); /** * An Event Adapter emitting event.key instead of any KeyboardEvent object * @returns EventSource<string> * * For multi-step input pipelines, see {@link key | key (lowercase)} * * ## Examples * * ### Display the last key pressed in a text box * * The following illustrates how to get any key presses from the text box * and only pass the actual character through to the main stream * * ```ts * import { Subject } from 'rxjs'; * import { rml, Key } from 'rimmel'; * * const Component = () => { * const stream = new Subject<string>(); * * return rml` * Last character pressed: "<span>${stream}"</span>" * * <input onkeypress="${Key(stream)}> * `; * } * ``` * * ### Collect distinct characters typed in a text box * * The following illustrates how to get any key presses from the text box * and only pass the actual character through to the main stream * * ```ts * import { Subject, distinct, scan } from 'rxjs'; * import { rml, Key } from 'rimmel'; * * const Component = () => { * const stream = new Subject<string>().pipe( * distinct(), * scan((a, b) => a.concat(b)), * ); * * return rml` * Unique characters pressed: "<span>${stream}"</span>" * * <input onkeypress="${Key(stream)}> * `; * } * ``` */ const Key = inputPipe(key); /** * An Event Source Operator emitting the "[event.clientX, event.clientY]" coordinates * @returns OperatorFunction<Coords> */ const clientXY = rxjs.map((e) => [e.clientX, e.clientY]); /** * An Event Source emitting the "[event.clientX, event.clientY]" coordinates * @returns EventSource<Coords> */ const ClientXY = inputPipe(clientXY); /** * An Event Source Operator emitting the "[event.offsetX, event.offsetY]" coordinates * @returns OperatorFunction<PointerEvent, <Coords> */ const offsetXY = rxjs.map((e) => [e.offsetX, e.offsetY]); /** * An Event Adapter emitting the "[event.offsetX, event.offsetY]" coordinates * @returns EventSource<Coords> */ const OffsetXY = inputPipe(offsetXY); /** * An Event Operator emitting the "[x, y]" coordinates of the last touch event * @returns OperatorFunction<TouchEvent, Coords> */ const lastTouchXY = rxjs.map((e) => { const t = [...e.touches].at(-1); return [t?.clientX, t?.clientY]; }); /** * An Event Source emitting the "[x, y]" coordinates of the last touch event * @returns EventSource<Coords> */ const LastTouchXY = inputPipe(lastTouchXY); /** * An Event Source Operator that "cuts" the value of the underlying <input> element * and resets it to the provided value or empty otherwise * @param handler A handler function or observer to send events to * @returns EventSource<string> */ const swap = (replacement) => rxjs.map((e) => { const t = e.target; const v = t.value; t.value = typeof replacement == 'function' ? replacement(v) : replacement; return v; }); /** * An Event Source that "cuts" the value of the underlying &lt;input&gt; element * and resets it to the provided value or empty otherwise * @param replacement A new value to swap the current element's value with * @param source A handler function or observer to send events to * @returns EventSource<string> */ const Swap = (replacement = '', source) => curry(swap(replacement), source); const maybeLift = (v) => v.subscribe ? v : v.then ? rxjs.from(v) : rxjs.of(v); /** * WIP: don't use yet * Emits the latest value coming from the supplied observable */ const AsLatestFrom = (source, target) => curry(rxjs.pipe(rxjs.withLatestFrom(maybeLift(source)), rxjs.map(([_, source]) => source)), target); /** * An Event Operator that emits the value of the underlying &lt;input&gt; element into a target observer * * @category Event Adapter Operators * @returns OperatorFunction<Event, string | number | date | null> * * For simple, one-step input pipelines, see the {@link Cut | Cut (uppercase)} Event Adapter * * ## Examples * * ### ValidInfo * * Create a custom Event Operator that feeds a stream with validated data, once a custom validator passes * * ```ts * import { Subject, filter } from 'rxjs'; * import { rml, inputPipe, value } from 'rimmel'; * * const isValid = filter((s: string) => IS_VALID_IMPL(s) ); * const ValidInfo = inputPipe(value, isValid); * * const Component = () => { * const stream = new Subject<string>(); * * return rml` * <input type="text" onchange="${ValidInfo(stream)}" autofocus> * [ <span>${stream}</span> ] * `; * }; * ``` */ const value = rxjs.map((e) => autoValue(e.target)); /** * An Event Adapter emitting the value of the underlying &lt;input> element instead of a regular DOM Event object * * ## Example * Copy the value of a text box on change * * ```ts * import { Subject } from 'rxjs'; * import { rml, Value } from 'rimmel'; * * const Component = () => { * const stream = new Subject<string>(); * * return rml` * <input type="text" onchange="${Value(stream)}" autofocus> * [ <span>${stream}</span> ] * `; * }; * ``` */ const Value = inputPipe(value); /** * An Event Source Operator emitting the value of the underlying &lt;input> element instead of a regular DOM Event object * @returns OperatorFunction<Event, string> */ const valueAsString = rxjs.map((e) => e.target.value); /** * An Event Source Operator for valueAsNumber * Emits the numeric value of the underlying &lt;input type="number"> or &lt;input type="range"> instead of a regular DOM Event object * @returns OperatorFunction<Event, number | null> */ const valueAsNumber = rxjs.map((e) => e.target.valueAsNumber); /** * An Event Source for valueAsNumber * Emits the numeric value of the underlying &lt;input type="number"> or &lt;input type="range"> instead of a regular DOM Event object * @returns EventSource<number> */ const ValueAsNumber = inputPipe(valueAsNumber); /** * An Event Source Operator for valueAsDate * Emits the numeric value of the underlying `<input type="date">` instead of a regular DOM Event object * @returns OperatorFunction<Event, Date | null> */ const valueAsDate = rxjs.map((e) => e.target.valueAsDate); /** * An Event Adapter for valueAsDate * Emits the numeric value of the underlying `<input type="date">` instead of a regular DOM Event object * @returns EventSource<Date | null> */ const ValueAsDate = inputPipe(valueAsDate); const AnyContentSink = (node) => (htmlSource) => { asap((html) => node.innerHTML = html, htmlSource); }; const camelCase = (s) => s.split('-').map((s, i) => i ? s[0].toLocaleUpperCase() + s.slice(1) : s).join(''); const DatasetSink = (node, key) => { const { dataset } = node; return (str) => { dataset[camelCase(key)] = str; }; }; const DatasetItemPreSink = (key) => (node) => { const { dataset } = node; return (str) => { dataset[camelCase(key)] = str; }; }; const DatasetObjectSink = (node) => { const { dataset } = node; return (data) => { for (const [key, str] of Object.entries(data ?? {})) { const camelKey = camelCase(key); (str === undefined || str == null) ? delete dataset[camelKey] : asap((str) => dataset[camelKey] = str, str); } }; }; const APPEND_HTML_SINK_TAG = 'appendHTML'; const AppendHTMLSink = (node) => node.insertAdjacentHTML.bind(node, 'beforeend'); /** * A specialised sink to append HTML to the end of an element * @param source A present or future HTML string * @returns RMLTemplateExpression An HTML-subtree or RML template expression * @example <div>${AppendHTML(stream)}</div> */ const AppendHTML = (source, pos = 'beforeend') => ({ type: SINK_TAG, t: APPEND_HTML_SINK_TAG, source, sink: AppendHTMLSink, params: pos, }); const BLUR_SINK_TAG = 'blur'; const BlurSink = (node) => node.blur.bind(node); /** * A specialised sink for the "rml:blur" RML attribute * @param source A present or future boolean value * @returns RMLTemplateExpression A template expression for the "rml:blur" attribute * @example <input type="text" rml:blur="${booleanValue}"> * @example <input type="text" rml:blur="${booleanPromise}"> * @example <input type="text" rml:blur="${booleanObservable}"> * @example <input type="text" rml:blur="${Blur(booleanObservable)}"> */ const Blur = (source) => ({ type: SINK_TAG, t: BLUR_SINK_TAG, source, sink: BlurSink, }); const CHECKED_SINK_TAG = 'checked'; const CheckedSink = (node) => (checked) => { node.checked = checked; }; /** * A specialised sink for the "checked" HTML attribute * @param source A present or future boolean value * @returns RMLTemplateExpression A template expression for the "checked" DOM attribute * @example <input type="checkbox" checked="${booleanValue}"> * @example <input type="checkbox" checked="${booleanPromise}"> * @example <input type="checkbox" checked="${booleanObservable}"> * @example <input type="checkbox" checked="${Checked(booleanPromise)}"> */ const Checked = (source) => ({ type: SINK_TAG, t: CHECKED_SINK_TAG, source, sink: CheckedSink, }); const TOGGLE_CLASS_SINK_TAG = 'ToggleClass'; const ToggleClassSink = (className) => (node) => node.classList.toggle.bind(node.classList, className); const ClassNameSink = (node) => (str) => node.className = str; const ClassObjectSink = (node) => { const cl = node.classList; const set = (str) => node.className = str; const add = cl.add.bind(cl); const remove = cl.remove.bind(cl); cl.toggle.bind(cl); return (name) => { typeof name == 'string' ? name.includes(' ') ? set(name) : add(name) // FIXME: is it safe to assume it's an object, at this point? : [].concat(name).forEach(obj => Object.entries(obj) // TODO: support 3-state with toggle .forEach(([k, v]) => asap(v ? add : remove, k))); }; }; /** * A specialised sink to toggle individual classes on a given element * Will toggle the specified class name on the specified element, whenever the source emits. The actual value of the source will be ignored, as it's the emissions which will cause the class toggling. * @param source A present or future string * @param className The class name to toggle * @returns RMLTemplateExpression A template expression for the "className" attribute * @example <div class="${ToggleClassName('class1', stringPromise)}"> * @example <div class="${ToggleClassName('class2', stringObservable)}"> **/ const ToggleClass = (source, className) => ({ type: SINK_TAG, t: TOGGLE_CLASS_SINK_TAG, source, sink: ToggleClassSink(className), }); /** * A specialised sink for the "class" HTML attribute * Will set the whole className of an element to the string emitted by the source * @param source A present or future string * @returns RMLTemplateExpression A template expression for the "className" attribute * @example <div class="${stringPromise}"> * @example <div class="${stringObservable}"> * @example <div class="${ClassName(stringObservable)}"> **/ const ClassName = (source) => ({ type: SINK_TAG, t: 'ClassName', source, sink: ClassNameSink, }); const CLOSED_SINK_TAG = 'closed'; /** * A sink that closes a &lt;dialog&gt; element when a source streams emits * @param dialogBox an HTMLDialogElement * @returns */ const ClosedSink = (dialogBox) => dialogBox.close.bind(dialogBox); /** * An explicit sink that closes a &lt;dialog&gt; element when a source streams emits a non falsey value * * You can call this sink in the following ways: * - implicitly, by assigning it to the `rml:clos