From 22e8b26c99ac37341338268c6807df70e7645f36 Mon Sep 17 00:00:00 2001 From: DM Date: Mon, 9 Dec 2024 00:56:49 +0000 Subject: [PATCH] 1.3 --- .stackblitzrc | 3 + README.md | 100 +++++-- examples/kitchen-sink/index.ts | 395 ++++++++++++++++++++++++---- package-lock.json | 6 +- package.json | 5 +- src/constants.ts | 8 +- src/custom-element.ts | 3 +- src/index.ts | 12 +- src/lib/drain.ts | 51 ++-- src/lifecycle/data-binding.ts | 10 +- src/lifecycle/event-delegation.ts | 3 +- src/parser/parser.test.ts | 4 +- src/parser/parser.ts | 12 +- src/parser/sink-map.ts | 3 + src/sinks/checked-sink.ts | 18 +- src/sinks/sanitize-html-sink.ts | 3 +- src/sinks/style-sink.ts | 29 +- src/sinks/subtree-sink.ts | 34 +++ src/sources/checked-source.test.ts | 52 ++++ src/sources/checked-source.ts | 19 ++ src/sources/cut-source.test.ts | 54 ++++ src/sources/cut-source.ts | 47 ++-- src/sources/dataset-source.ts | 47 ++-- src/sources/event-data.ts | 20 +- src/sources/form-data-source.ts | 20 +- src/sources/keyboard-source.ts | 10 +- src/sources/last-touch-xy-source.ts | 15 +- src/sources/numberset-source.ts | 24 +- src/sources/object-source.test.ts | 19 +- src/sources/object-source.ts | 45 ++-- src/sources/offset-xy-source.ts | 13 +- src/sources/swap-source.ts | 34 +++ src/sources/value-source.ts | 50 +++- src/types/dom-observable.d.ts | 8 +- src/types/internal.ts | 6 +- src/types/sink.ts | 8 +- src/utils/auto-value.ts | 21 +- src/utils/curry.ts | 16 ++ src/utils/input-pipe.ts | 2 + tsconfig.base.json | 5 +- 40 files changed, 948 insertions(+), 286 deletions(-) create mode 100644 .stackblitzrc create mode 100644 src/sinks/subtree-sink.ts create mode 100644 src/sources/checked-source.test.ts create mode 100644 src/sources/checked-source.ts create mode 100644 src/sources/cut-source.test.ts create mode 100644 src/sources/swap-source.ts create mode 100644 src/utils/curry.ts diff --git a/.stackblitzrc b/.stackblitzrc new file mode 100644 index 0000000..42deadf --- /dev/null +++ b/.stackblitzrc @@ -0,0 +1,3 @@ +{ + "startCommand": "npm run kitchen-sink" +} diff --git a/README.md b/README.md index 326ab06..f467a8d 100644 --- a/README.md +++ b/README.md @@ -4,33 +4,32 @@ [![npm](https://img.shields.io/npm/v/rimmel.svg)](https://www.npmjs.com/package/rimmel) With Rimmel you can create webapps that are: -1. **Faster** than equivalent webapps built with popular "Virtual DOM" or "Change-Detection" based frameworks (e.g.: React, Vue, Angular) -2. 50-90% **lighter** than equivalent applications written with many other imperative frameworks (e.g.: Angular, React, Vue, SOLID, Svelte) -3. More easily **testable** than equivalent applications written with most other imperative frameworks (e.g.: Angular, React, Vue, SOLID, Svelte, Nue, VanJS) +1. **Outperforming** equivalent webapps built with popular "Virtual DOM" or "Change-Detection" based frameworks (e.g.: React, Vue, Angular) +2. 50-90% **Lighter** than equivalent applications written with many other imperative frameworks (e.g.: Vue, SOLID, Angular, React, Svelte) +3. More **Eeasily Testable** compared to most imperative frameworks (e.g.: Svelte, React, Vue, SOLID ) -Really?
-Yes, this is why: -1. Rimmel only uses reactive primitives that make direct DOM manipulation, without the need of any tree-traversal logic or similar overhead -2. It's fairly normal for larger Rimmel webapps to be only about 100-200KB of total minzipped JavaScript code -3. Rimmel supports the functional-reactive paradigm which not only makes it easier to test functionality, but it actually needs less testing for the same code quality +Really? Yes, here's why: +1. It bypasses heavy and overcomplicated tree-traversal overhead by only using reactive primitives that can make direct DOM manipulation +2. It's fairly normal for larger Rimmel webapps to be less than 200KB of total minzipped JavaScript code. The use of RxJS and Observables guarantee that +3. Adopting the functional-reactive paradigm, with no side-effects, not only makes it easier to test functionality, but it actually requires far less test cases to match the same code coverage
Rimmel is built around the Observable and Observer patterns.
-To put it simply, DOM events are Observables, everything else (innerHTML, class names, attributes) is Observers. +To put it simply, DOM events are Observables, the rest (innerHTML, class names, attributes) are Observers. ```html - + - -
-
${observable3}
+ +
+
${stream4}
``` No need for JSX, Virtual DOM, Babel, HyperScript, Webpack, React.
No need to "set up" or "tear down" observables in your components, so you can keep them pure.
-No need to unsubscribe or dispose of observers or perform any manual memory cleanup. +No need to subscribe, unsubscribe or dispose of observers or perform any manual memory cleanup.
@@ -38,7 +37,7 @@ Rimmel works with standard JavaScript/TypeScript template literals tagged with `
## 👋 Hello World 👋 -The modern "Hello World" for reactive interfaces is the click counter: one button, you click it, he counts it.
+The "Hello World" for reactive interfaces is the click counter: one button, you click it, he counts it.
This is how it works: How RimmelJS Works @@ -264,9 +263,8 @@ Sinks are most often the place where you want to display any information in the With RML/Rimmel you can treat most DOM elements as sources, sinks, or both. ## Event Sources -Rimmel supports event listeners from all DOM elements. -Static values are treated as non-observable values and no data-binding will be created. -Observers such as Subjects and BehaviorSubjects will receive events as emitted by the DOM. +Rimmel supports event native and synthetic listeners from all DOM elements. +All Observers such as event listener functions, Subjects and BehaviorSubjects will receive events as emitted by the DOM. ### Examples: @@ -299,7 +297,7 @@ target.innerHTML = rml` ### Event Adapters In normal circumstances your event handlers receive a native DOM `Event` object, such as `MouseEvent`, `PointerEvent`, etc. -To enable a better separation of concerns, as of Rimmel 1.2 you can use Event Mappers, or Event Adapters to feed your event handlers or Observable streams the exact data they need, in the format they expect it, rather than the generic, raw DOM Event objects. +To enable a better separation of concerns, as of Rimmel 1.2 you can use Event Adapters to feed your event handlers or Observable streams the exact data they need, in the format they expect it, rather than the generic, raw DOM Event objects. Do you only need the relative `[x, y]` mouse coordinates when hovering an element?
Use `
` @@ -308,17 +306,21 @@ Do you want the last typed character when handling keyboard events? Use `` Rimmel comes with a handful of Event Adapters out of the box, but you can create your own with ease. +Event Adapters become particularly useful when you have a main data stream that you want to feed from different elements of different types that emit different events. Using an adapter for each, you can make sure you always get data in a unified format in your main data stream. + +### Event Adapter Operators +In certain cases it can be useful to have an Event Adapter made of multiple steps. +Adapter Operators are simple RxJS operators that make it possible to create whole event adapter pipelines to better suit mode advanced needs. If you know how to use the `pipe()` function from RxJS, then you almost know how to use `source(...operators, target)` from Rimmel. -It works like `pipe()`, except it applies the same operators to data coming in, rather than going out of an Observable stream. +It works like `pipe()`, except it applies the same operators to data coming in, rather than going out of an Observable stream: `source(...operators, targetObserver)` -`source(...operators, targetObserver)` ```js import { rml, source } from 'rimmel'; -const DatasetValue = map((e: Event) => Number(e.target.dataset.value)); -const ButtonClick = filter((e: Event) => e.target.tagName == 'BUTTON'); +const datasetValue = map((e: Event) => Number(e.target.dataset.value)); +const buttonClick = filter((e: Event) => e.target.tagName == 'BUTTON'); const Component = () => { const total = new Subject().pipe( @@ -326,7 +328,7 @@ const Component = () => { ); return rml` -
+
@@ -343,6 +345,52 @@ As you can see, the main data model, which is the observable stream called `tota The `DatasetValue` Event Adapter translates raw DOM events into the plain numbers required by the model. Finally, we're leveraging the DOM's standard Event Delegation by only adding one listener to the container, rather than to each button. We're making sure only button clicks are captured by using the `ButtonClick` filter + +### Conventions to distinguish Event Adapters from Adapter Operators +The main difference between Event Adapters and Adapter Operators is that the former are designed for simplicity and convenience. Their name begins with an uppercase letter. +Adapter Operators follow the practice of other RxJS operators, their name is camelcased and can be composed together just like other RxJS operators. + +Rimmel often exports both. For instance, the `Cut` Adapter, or the `cut` Operator clear the value of a text input before emitting its value. They are convenient ways to let users repeatedly enter information and clean up a text field for the next input. + +The following example illustrates the use of the `Cut` Event Adapter to feed a simple text stream. + +```js +import { rml, Cut } from 'rimmel'; +import { Subject } from 'rxjs'; + +const Component = () => { + const stream1 = new Subject(); + + return rml` +
+ + Input text: ${stream1} + `; +}; +``` + +The following example, will instead use the `cut` Operator, combined with an `onEnterKey` filter and a `toUpperCase` operator to only emit and clean up the field's value when the user presses `Enter`. + + +```js +import { map, filter } from 'rxjs'; +import { rml, source, cut } from 'rimmel'; + +const Component = () => { + const onEnterKey = filter((e: KeyboardEvent) => e.key == 'Enter'); + const toUpperCase = map(s: string) => s.toUpperCase(); + + const stream2 = new Subject(); + + return rml` +
+ + Uppercased input text entered: ${stream2} + `; +}; +``` +Please note how the last parameter of `source()` is `stream2` the actual target of the input pipeline. +
## Data Sinks @@ -676,10 +724,12 @@ npm run build There is a "kitchen sink" app you can use to play around locally, which should showcase most of what you can do with Rimmel: ```bash -cd examples/kitchen-sink +npm run kitchen-sink vite ``` +Or you can just run it with one click [on StackBlitz](https://stackblitz.com/~/github.com/ReactiveHTML/rimmel?file=examples/kitchen-sink/index.ts&startScript=kitchen-sink) + # Web Standards There are discussions going on around making HTML and/or the DOM natively support Observables at [WHATWG DOM/544](https://github.com/whatwg/dom/issues/544) and especially the more recent [Observable DOM](https://github.com/WICG/observable). diff --git a/examples/kitchen-sink/index.ts b/examples/kitchen-sink/index.ts index 1ef9055..ec33ccb 100644 --- a/examples/kitchen-sink/index.ts +++ b/examples/kitchen-sink/index.ts @@ -1,37 +1,168 @@ import type { HTMLString, SinkBindingConfiguration, Stream } from '../../src/index'; -import { BehaviorSubject, Observable, Subject, interval, filter, map, merge, mergeWith, of, pipe, scan, startWith, switchMap, take, tap, throwError, withLatestFrom, catchError, ObservedValueOf } from 'rxjs'; -import { rml, All, feedIn, source, sink, inputPipe, AppendHTML, InnerText, InnerHTML, Removed, Sanitize, TextContent, Update, Suspend } from '../../src/index'; -import { Value, ValueAsDate, ValueAsNumber, Dataset, EventData, Form, JSONDump, Target, Key, OffsetXY, Numberset, inputPipe, pipeIn } from '../../src/index'; +import { + BehaviorSubject, + Subject, + + catchError, + filter, + interval, + map, + merge, + mergeWith, + Observable, + of, + scan, + startWith, + take, + tap, +} from 'rxjs'; + +import { + rml, + + inputPipe, + pipeIn, + + AppendHTML, + cut, + Cut, + Dataset, + DatasetObject, + eventData, + EventData, + form, + Form, + InnerHTML, + InnerText, + JSONDump, + Key, + Numberset, + OffsetXY, + Removed, + Sanitize, + sink, + source, + Swap, + TextContent, + Update, + Value, + ValueAsDate, + ValueAsNumber, + value, +} from '../../src/index'; +import { set_USE_DOM_OBSERVABLES } from '../../src/index'; import { char } from '../../src/types/basic'; import { RegisterElement } from '../../src/custom-element'; -const log = (p) => tap(x=>console.log(p, x)) +const Log = (p) => tap(x=>console.log(p, x)) +const log = tap(console.log); +const step = tap(x => { + debugger; +}); +const upperCase = map((s: string)=>s.toUpperCase()); -RegisterElement('custom-element', ({ title, content, onbuttonclick, onput }) => { - const handle = e => { - console.log('Internal event', e); - onput.next(e); - } +const timer = interval(1000); - return rml` -
-

${title}

-

${content}

- Custom Element Works!
- - -
- `; -}); -// const defer = (...x: any) => new Promise((resolve, reject) => setTimeout(resolve, 5000, ...x)); const defer = (x: T, timeout: number = 500): Promise => new Promise(resolve => setTimeout(resolve, timeout, x)); //////////////////////////////////////////////// const sources = { + DOM_OBSERVABLES: () => { + const NativeStream = () => { + const subscribers = []; + const o = new window.Observable(subscriber => { + const pos = subscribers.push(subscriber); + const unsub = () => { + subscribers.splice(pos, 1); + }; + unsub.unsubscribe = unsub; + return unsub; + // signal = observer.signal + }); + o.next = (data) => { + subscribers.forEach(l=>l.next ? l.next(data) : l(data)); + } + o.error = (e) => output?.error(data); + o.complete = () => output?.complete(); + o.subscribe = (l) => { + const pos = subscribers.push(l); + const unsub = () => { + subscribers.splice(pos, 1); + }; + unsub.unsubscribe = unsub; + return unsub; + } + return o; + } + + const input = NativeStream(); + const output = input.map(s=>s.toUpperCase()); + + output.subscribe({ + next: x=>console.log('>>>', x) + }); + + const strHTML = rml` + +
${output}
+ `; + + set_USE_DOM_OBSERVABLES(true); + setTimeout(()=>set_USE_DOM_OBSERVABLES(false), 10); + return strHTML; + }, + + SYNC_RENDERING: () => { + const stream = new Subject().pipe( + startWith('initial value'), + ); + + const strHTML = rml` + +
${stream}
+ `; + + console.log('Initial HTML:', strHTML); + return strHTML; + }, + + MULTILINE: () => { + const id = 'xxx'; + const stream = interval(1000); + + const strHTML = rml` +
+ ${InnerText(stream)} +
+ `; + + console.log('HTML:', strHTML); + return strHTML; + }, + + SVG: () => { + const stream = interval(50).pipe( + take(21), + map(i => ``) + ); + + const strHTML = rml` + + ${AppendHTML(stream)} + + `; + + return strHTML; + }, + ObjectSourceImplicit: () => { const data = { prop1: undefined @@ -43,7 +174,7 @@ const sources = { return rml` Updating a non-reactive property of an object
-
+
data.prop1 = ${stream} @@ -62,7 +193,7 @@ const sources = { return rml` Updating a non-reactive property of an object
-
+
data.prop1 = ${stream} @@ -90,24 +221,78 @@ const sources = { }, ValueAsDateSource: () => { - const stream = new Subject().pipe( + const stream = new Subject() + const yesterday = stream.pipe( map(d=> { - d?.setDate(d.getDate() + 1); + d?.setDate(d.getDate() -1); + return d?.toDateString() ?? ''; + }) + ); + const tomorrow = stream.pipe( + map(d=> { + d?.setDate(d.getDate() +1); return d?.toDateString() ?? ''; }) ); return rml` Today is:
- Tomorrow: ${stream} + Yesterday: ${yesterday}
+ Tomorrow: ${tomorrow}
`; }, - FormDataSource: () => { + Cut: () => { const stream = new Subject(); return rml` -
+ + [ ${stream} ] + `; + }, + + CutPipeline: () => { + const stream = new Subject(); + + return rml` + + [ ${stream} ] + `; + }, + + UpperCut: () => { + const stream = new Subject(); + const UpperCut = inputPipe(cut, upperCase); + + return rml` + + [ ${stream} ] + `; + }, + + Swap: () => { + const stream = new Subject(); + + return rml` + + [ ${stream} ] + `; + }, + + Swap_Fn: () => { + const stream = new Subject(); + + return rml` + + [ ${stream} ] + `; + }, + + Form: () => { + const stream = new Subject(); + + return rml` + @@ -118,6 +303,20 @@ const sources = { `; }, + form: () => { + const stream = new Subject(); + + return rml` + + + + + + + + Result: ${JSONDump(stream)} + `; + }, pipeIn: () => { const stream = new Subject(); @@ -145,12 +344,23 @@ const sources = { return rml` Stream: ${stream}
- + `; }, - DatasetSource: () => { + Dataset: () => { + const stream = new Subject(); + + return rml` + +
+ data-foo = ${stream} + `; + }, + + + Dataset_Curried: () => { const stream = new Subject(); const JustFoo = Dataset('foo'); @@ -161,16 +371,13 @@ const sources = { `; }, - Source_Stream_Pipelines: () => { - const dataset = map((e: Event)=>(e.target).dataset) - const JSONPrint = map(s=>`
${JSON.stringify(s, null, 2)}
`) - + DatasetObject: () => { const stream = new Subject(); return rml` - +
- data-foo = ${sink(stream, JSONPrint)} + dataset = ${sink(stream, JSONDump)} `; }, @@ -202,7 +409,7 @@ const sources = { `; }, - 'KeyboardSource_EventData': () => { + 'EventData': () => { const stream = new Subject(); return rml` @@ -211,6 +418,16 @@ const sources = { `; }, + 'eventData': () => { + const stream = new Subject(); + + return rml` + +
Last key pressed: ${stream}
+ `; + }, + + 'KeyboardSource (Key)': () => { const stream = new Subject(); @@ -248,20 +465,12 @@ const sinks = { return rml`` }, - ZeroInitial: () => { - const bs = new BehaviorSubject(0).pipe( - mergeWith(interval(1000).pipe(map(x=>x+2))), - ); - - return rml`Outpuut: ${bs}`; - }, - EmptyInitial: () => { const bs = new BehaviorSubject('').pipe( mergeWith(interval(1000)), ); - return rml`Outpuut: ${bs}`; + return rml`Output: ${bs}`; }, ClassSink: () => { @@ -303,13 +512,14 @@ const sinks = { CustomElement: () => { const notify = (key: string) => void console.log('Notify', key); - const titleStream = interval(100).pipe( + const titleStream = interval(1000).pipe( map(i => `title ${i}`), ); return rml` Should be a custom element - +

red

+ `; }, @@ -340,7 +550,7 @@ const sinks = { `; }, - AddUpPipeInDelegation: () => { + EventDelegation: () => { const n = new BehaviorSubject(0); const count = n.pipe( @@ -412,7 +622,7 @@ const sinks = { BlurSink: () => { const keyStream = new Subject(); const blur = keyStream.pipe( - log('KEY'), + Log('KEY'), filter(k => k == 'Enter') ); @@ -571,6 +781,37 @@ const sinks = { `; }, + Mixin_Subtree: () => { + const counter = interval(1000); + + const mix = (args?: any) => { + const subtree = { + '.grandchild span': { + innerHTML: counter, + style: {color: 'red'}, + } + }; + + return { + subtree, + }; + }; + + return rml` +
+
+ Child element +
+ Grandchild element +
+
+ Grandchild element +
+ +
+ ` + }, + 'Removed (Implicit)': () => { const removed = new Subject(); @@ -657,18 +898,25 @@ const sinks = { `; }, + Confusions: () => { + const disabled = false; + return rml` + + `; + }, + + SyncDisabled__: () => { const disabled = false; - const clicked$ = new Subject().pipe( - tap(x=>console.log('in', x)), - map(() => 'clicked!'), - tap(x=>console.log('out', x)), + const click = new Subject().pipe( + scan(x => x+1, 0), ); return rml` - + +

-
Clicked? [${clicked$}]
+
Clicked? ${click}
`; }, @@ -1084,6 +1332,14 @@ const component = () => { margin-block: .2rem; } + .red { + color: red; + } + + custom-element .red { + color: blue; + } + .class1::before { display: inline-block; content: attr(data-deferred); @@ -1126,9 +1382,10 @@ const component = () => { color: #eee; } + a.btn:target, button:active, button:focus { - background-color: #888; + background-color: #33e; color: #111; } } @@ -1171,4 +1428,30 @@ const component = () => { `; } +RegisterElement('custom-element', ({ title, content, onbuttonclick, onput }) => { + const handle = e => { + console.log('Internal event', e); + onput.next(e); + } + + return rml` +
+ +

${title}

+

${content}

+ Custom Element Works!
+ + +
+ `; +}); + document.body.innerHTML = component(); diff --git a/package-lock.json b/package-lock.json index f9703c0..aaaeb33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "rimmel", - "version": "1.2.5", + "version": "1.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "rimmel", - "version": "1.2.5", + "version": "1.3.0", "license": "MIT", "devDependencies": { - "@rollup/plugin-commonjs": "^25.0.8", + "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-terser": "^0.4.4", diff --git a/package.json b/package.json index 66cba82..f5df4e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rimmel", - "version": "1.2.5", + "version": "1.3.0", "description": "A Functional-Reactive UI library for the Rx.Observable Universe", "type": "module", "_main": "dist/cjs/index.cjs", @@ -31,10 +31,11 @@ "dist/", "favicon.svg" ], - "sideEffects": false, + "sideEffects": true, "scripts": { "build": "rimraf dist && rollup --bundleConfigAsCjs --config rollup.config.ts --configPlugin typescript", "dev": "rimraf dist && rollup -w --bundleConfigAsCjs --config rollup.config.ts --configPlugin typescript", + "kitchen-sink": "cd examples/kitchen-sink && vite", "website:local": "bundle exec jekyll serve", "test": "bun test" }, diff --git a/src/constants.ts b/src/constants.ts index 818951c..f6b9dd2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,10 @@ -window.RMLREF='' +declare global { + interface Window { + RMLREF: string; + } +} +self.RMLREF=''; + export const REF_TAG: string = 'RMLREF+'; export const REF_REGEXP: RegExp = /^RMLREF+\d+$/; diff --git a/src/custom-element.ts b/src/custom-element.ts index 893b73f..a166852 100644 --- a/src/custom-element.ts +++ b/src/custom-element.ts @@ -44,7 +44,8 @@ class RimmelElement extends HTMLElement { super(); this.component = component; // this.#events = {}; - this.attachShadow({ mode: 'open' }); + const shadow = this.attachShadow({ mode: 'open' }); + // shadow.adoptedStyleSheets = [...]; const [attrs, events] = [...(this.attributes)].reduce((a, b) => { const isEvent = <0 | 1>+b.nodeName.startsWith('on'); diff --git a/src/index.ts b/src/index.ts index bb32120..e406fa2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,18 +30,20 @@ export { feed, feedIn, inputPipe, pipeIn } from './utils/input-pipe'; // Event Sources export { All, qs } from './sources/all-source'; -export { Dataset } from './sources/dataset-source'; -export { Cut } from './sources/cut-source'; +export { CheckedState } from './sources/checked-source'; +export { Cut, cut } from './sources/cut-source'; +export { Dataset, DatasetObject } from './sources/dataset-source'; export { Numberset } from './sources/numberset-source'; -export { EventData } from './sources/event-data'; +export { EventData, eventData } from './sources/event-data'; export { EventTarget } from './sources/event-target'; -export { Form } from './sources/form-data-source'; +export { Form, form } from './sources/form-data-source'; export { Key } from './sources/keyboard-source'; export { Update } from './sources/object-source'; export { ClientXY } from './sources/client-xy-source'; export { OffsetXY } from './sources/offset-xy-source'; export { LastTouchXY } from './sources/last-touch-xy-source'; -export { Value, ValueAsDate, ValueAsNumber } from './sources/value-source'; +export { Swap } from './sources/swap-source'; +export { Value, ValueAsDate, ValueAsNumber, value, valueAsString, valueAsDate, valueAsNumber } from './sources/value-source'; // Data Sinks export { AnyContentSink } from "./sinks/content-sink"; diff --git a/src/lib/drain.ts b/src/lib/drain.ts index c8df823..a4af137 100644 --- a/src/lib/drain.ts +++ b/src/lib/drain.ts @@ -1,60 +1,41 @@ import type { SinkFunction } from "../types/sink"; import type { EventListenerOrEventListenerObject } from "../types/dom"; import type { MaybeFuture, Observable, Present } from "../types/futures"; + import { isObservable, isPromise } from "../types/futures"; import { subscriptions } from "../internal-state"; +// FIXME: remove, use subscribe below instead export const asap = (fn: SinkFunction, arg: MaybeFuture) => { (>arg)?.subscribe?.(fn) ?? (>arg)?.then?.(fn) ?? fn(arg); }; -/** - * Call the given fn() with data, either now, or on subscription if it's a future. - * source.subscribe(fn) - * source.then(fn) - * fn(source) - **/ -export const consecute = (fn: (t: T) => void, source: MaybeFuture) => { - // TODO: add catch and complete handlers, too - // source.subscribe(nextFn, catchFn, endFn); - const subscription = - (>source)?.subscribe?.(fn) - ?? (>source)?.then?.(fn) - // ?? fn(source) ???? - ; - - if(subscription) { - // subscriptions.get(node)?.push(subscription) ?? subscriptions.set(node, [subscription]); - } else { - fn(>source); - } - return subscription; -}; - /** * Connect a source to a sink through a compatible interface - * @param node The current node on which the binding is set - * @param source A Promise, Observable, function or object - * @param nextFn A "next" or "then" handler on the sink side - * @param catchFn? An error handler on the sink side - * @param endFn? a finalisation function + * @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 */ -export const subscribe = (node: Node, source: MaybeFuture, nextFn: EventListenerOrEventListenerObject, catchFn?: (e: Error) => void, endFn?: () => void) => { +export const subscribe = (node: Node, source: MaybeFuture, next: EventListenerOrEventListenerObject, error?: (e: Error) => void, complete?: () => void) => { if (isObservable(source)) { // TODO: should we handle promise cancellations (cancellable promises?) too? - // TODO: should we handle function cancellations (removeEventListener) too? const subscription = source.subscribe({ - next: nextFn, - error: catchFn, - complete: endFn + next: next, + error, + complete, }); + subscriptions.get(node)?.push(subscription) ?? subscriptions.set(node, [subscription]); + return subscription; } else if (isPromise(source)) { - source.then(nextFn).catch(catchFn).finally(endFn); + source.then(next, error).finally(complete); } else { - (nextFn)(source); + // TODO: should we handle function cancellations (removeEventListener) too? + (next)(source); } } diff --git a/src/lifecycle/data-binding.ts b/src/lifecycle/data-binding.ts index 45b02ff..c367516 100644 --- a/src/lifecycle/data-binding.ts +++ b/src/lifecycle/data-binding.ts @@ -1,3 +1,4 @@ +import type * as ObservableDOM from "../types/dom-observable"; import type { RMLEventName } from "../types/dom"; import type { SourceBindingConfiguration } from "../types/internal"; @@ -18,6 +19,8 @@ const elementNodes = (n: Node): n is Element => n.nodeType == 1; const errorHandler = console.error; +const isEventListenerObject = (l: EventListenerOrEventListenerObject): l is EventListenerObject => typeof l == 'object' && 'handleEvent' in l + export const Rimmel_Bind_Subtree = (node: Element): void => { // Data-to-be-bound text nodes in an element (
${thing1} ${thing2}
); const intermediateInteractiveNodes: Node[] = []; @@ -27,6 +30,7 @@ export const Rimmel_Bind_Subtree = (node: Element): void => { }); // Interactive text nodes + // TODO: shall we use some ad-hoc container elements, instead? if(hasInteractiveTextNodes) { const nodes = <(Node | string)[]>[]; for (const n of node.childNodes) { @@ -110,8 +114,10 @@ export const Rimmel_Bind_Subtree = (node: Element): void => { // 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) { const l = sourceBindingConfiguration.listener; - const source = node.when(eventName) - source.subscribe(l); + if(!isEventListenerObject(l)) { + const source = node.when(eventName) + source.subscribe(l); + } } else { node.addEventListener(eventName, sourceBindingConfiguration.listener); } diff --git a/src/lifecycle/event-delegation.ts b/src/lifecycle/event-delegation.ts index cb2d6ac..5eb65d3 100644 --- a/src/lifecycle/event-delegation.ts +++ b/src/lifecycle/event-delegation.ts @@ -1,3 +1,4 @@ +import type * as ObservableDOM from "../types/dom-observable"; import type { RMLEventName } from "../types/dom"; import { RML_DEBUG, USE_DOM_OBSERVABLES } from "../constants"; @@ -11,7 +12,7 @@ export const delegateEvent = (eventName: RMLEventName) => { // TODO: allow registering deletegated event handlers at different levels than document // TODO: register at root element level, instead of document? - const handlerFn = (event: Event) => { + const handlerFn = (event: E) => { for ( var handledTarget = event.target, h = delegatedEventHandlers.get(event.target as Element); // TODO: use a Map to avoid h.some(conf=>conf.eventName == event.type) diff --git a/src/parser/parser.test.ts b/src/parser/parser.test.ts index 3c6c8dc..0eb473d 100644 --- a/src/parser/parser.test.ts +++ b/src/parser/parser.test.ts @@ -15,8 +15,8 @@ describe('Parser', () => { const handlerFn = () => {}; const template = rml`
Hello
`; - expect(template).toEqual('
Hello
'); - expect(waitingElementHanlders.get('#REF0')).toEqual([{ + expect(template).toEqual('
Hello
'); + expect(waitingElementHanlders.get('RMLREF+0')).toEqual([{ eventName: 'click', listener: handlerFn, type: 'source', diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 01c9bc8..15afdce 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -16,7 +16,7 @@ import { PreSink } from "../sinks/index"; import { sinkByAttributeName } from '../parser/sink-map'; import { DOMAttributePreSink, FixedAttributePreSink, WritableElementAttribute } from "../sinks/attribute-sink"; import { Mixin } from "../sinks/mixin-sink"; -import { ObjectSource, isObjectSource } from "../sources/object-source"; +import { ObjectSource, ObjectSourceExpression, isObjectSource } from "../sources/object-source"; import { ObserverSource, isObserverSource } from "../sources/observer-source"; import { isPromise } from '../types/futures'; @@ -50,6 +50,7 @@ export function rml(strings: TemplateStringsArray, ...expressions: RMLTemplateEx const ref = existingRef ?? `${REF_TAG}${state.refCount++}`; // Determine in which template context is any given expression appearing + // Then, depending on the context, call matching parser modules and (yet-to-be-created) registered parser plugins //const context = // />\s*$/.test(string) && /^\s*<\s*/.test(nextString) ? 'child/subtree' // : /(?[a-z0-9\-_]+)\=(?['"]?)(?[^"]*)$/.exec(resultPlusString) ? 'attribute' @@ -86,7 +87,7 @@ export function rml(strings: TemplateStringsArray, ...expressions: RMLTemplateEx const listener = isFunction(expression) ? expression : isObserverSource(expression) ? ObserverSource(expression) - : isObjectSource(expression) ? ObjectSource(expression) + : isObjectSource(expression) ? ObjectSource(...(expression as ObjectSourceExpression)) : null // We allow it to be empty. If so, ignore, and don't connect any source. Perhaps add a warning in debug mode? ; @@ -112,7 +113,7 @@ export function rml(strings: TemplateStringsArray, ...expressions: RMLTemplateEx // // } else if(typeof ((>expression).subscribe ?? (>expression).then) == 'function' && i; const attributeName = isAttribute.groups!.attribute; if(attributeName == 'style') { - const CSSAttribute = /;?(?[a-z][a-z0-9\-_]*)\s*:\s*$/.exec(string)?.groups?.key; + const CSSAttribute = /;?(?[a-z\-][a-z0-9\-_]*)\s*:\s*$/.exec(string)?.groups?.key; sink = CSSAttribute ? StylePreSink(CSSAttribute): StyleObjectSink; handler = PreSink('StyleObject', sink, expression, CSSAttribute); } else { @@ -178,7 +179,8 @@ export function rml(strings: TemplateStringsArray, ...expressions: RMLTemplateEx acc = (prefix +(initialValue ?? '')).replace(/<(\w[\w-]*)\s+([^>]+)$/, `<$1 ${existingRef?'':`${RESOLVE_ATTRIBUTE}="${ref}" `}$2`); } - } else if(/<\S+(?:\s+[a-z0-9_][a-z0-9_-]*(?:=(?:'[^']*'|"[^"]*"|\S+|[^>]+))?)*(?:\s+\.\.\.)?$/.test(accPlusString.substring(lastTag)) && /^(?:[^<]*>|\s+\.\.\.)/.test(nextString)) { + //} else if(/<\S+(?:\s+[a-z0-9_][a-z0-9_-]*(?:=(?:'[^']*'|"[^"]*"|\S+|[^>]+))?)*(?:\s+\.\.\.)?$/.test(accPlusString.substring(lastTag)) && /^(?:[^<]*>|\s+\.\.\.)/.test(nextString)) { + } else if(/[a-z0-9_][a-z0-9_-][^>]+(?:\s+\.\.\.)?$/ig.test(accPlusString.substring(lastTag)) && /^(?:[^<]*>|\s+\.\.\.)/.test(nextString)) { // FIXME: ^ ^^^^^^^^^ why are we doing this? // Mixin Sink // Use Cases: diff --git a/src/parser/sink-map.ts b/src/parser/sink-map.ts index d6c466a..8ccd43f 100644 --- a/src/parser/sink-map.ts +++ b/src/parser/sink-map.ts @@ -13,6 +13,7 @@ import { InnerHTMLSink } from "../sinks/inner-html-sink"; import { InnerTextSink } from "../sinks/inner-text-sink"; import { ReadonlySink } from "../sinks/readonly-sink"; import { RemovedSink } from "../sinks/removed-sink"; +import { SubtreeSink } from "../sinks/subtree-sink"; import { StyleObjectSink } from "../sinks/style-sink"; import { TextContentSink } from "../sinks/text-content-sink"; import { ToggleAttributePreSink } from "../sinks/attribute-sink"; @@ -41,7 +42,9 @@ export const sinkByAttributeName = new Map( ['rml:focus', FocusSink], // ['rml:readonly', ReadonlySink], // Can make this one act as an enumerated attribute that understands "false" and other values... ['rml:removed', RemovedSink], + ['rml:subtree', SubtreeSink], ['removed', RemovedSink], + ['subtree', SubtreeSink], ]); diff --git a/src/sinks/checked-sink.ts b/src/sinks/checked-sink.ts index f2bf884..db8a53b 100644 --- a/src/sinks/checked-sink.ts +++ b/src/sinks/checked-sink.ts @@ -4,9 +4,9 @@ import type { Sink, ExplicitSink } from "../types/sink"; import { SINK_TAG } from "../constants"; export const CheckedSink: Sink = (node: HTMLInputElement) => - (checked: boolean) => { - node.checked = checked - }; + (checked: boolean) => { + node.checked = checked + }; /** * A specialised sink for the "checked" HTML attribute @@ -17,10 +17,10 @@ export const CheckedSink: Sink = (node: HTMLInputElement) => * @example */ export const Checked: ExplicitSink<'checked'> = (source: RMLTemplateExpressions.BooleanAttributeValue<'checked'>) => - >({ - type: SINK_TAG, - t: 'checked', - source, - sink: CheckedSink, - }) + >({ + type: SINK_TAG, + t: 'checked', + source, + sink: CheckedSink, + }) ; diff --git a/src/sinks/sanitize-html-sink.ts b/src/sinks/sanitize-html-sink.ts index 8d42077..2969c2d 100644 --- a/src/sinks/sanitize-html-sink.ts +++ b/src/sinks/sanitize-html-sink.ts @@ -28,7 +28,8 @@ const sanitizeNode = (node: Element | DocumentFragment) => { function sanitizeInput(input: HTMLString, target: Element) { const tempElement = document.createElement('div'); - tempElement.innerHTML = input; + tempElement.textContent = input; + const fragment = document.createDocumentFragment(); fragment.append(...tempElement.childNodes); diff --git a/src/sinks/style-sink.ts b/src/sinks/style-sink.ts index 9d2eba4..beb923f 100644 --- a/src/sinks/style-sink.ts +++ b/src/sinks/style-sink.ts @@ -3,6 +3,12 @@ import type { Sink } from "../types/sink"; import { asap } from "../lib/drain"; +const getCSSPropertySetter = (style: CSSStyleDeclaration, key: K) => + /^--/.test(key as string) + ? (value: CSSValue) => { value == null ? style.removeProperty(key as string) : style.setProperty(key as string, value as string); } + : (value: CSSValue) => { style[key] = value; } +; + /** * Applies a given CSS value to a specified CSS property of an Element. * @@ -15,22 +21,17 @@ import { asap } from "../lib/drain"; * const setBackgroundColor = styleSink(divElement, 'backgroundColor'); * setBackgroundColor('red'); // Sets the div's background color to red **/ -export const StyleSink: Sink = (node: HTMLElement | SVGElement, key: K) => { - const style = node.style; - return (value: CSSValue) => { - style[key] = value; - } -}; +export const StyleSink: Sink = (node: HTMLElement | SVGElement, key: K) => + getCSSPropertySetter(node.style, key); +; export const StylePreSink = (key: CSSWritableProperty) => - (node: HTMLElement | SVGElement) => - StyleSink(node, key) + (node: HTMLElement | SVGElement) => + StyleSink(node, key) ; -export const StyleObjectSink: Sink = (node: HTMLElement | SVGElement) => { - const style = node.style; - return (kvp: CSSDeclaration) => - Object.entries(kvp ?? {}).forEach(([k, v]) => asap(v => style[k] = v, v)) +export const StyleObjectSink: Sink = (node: HTMLElement | SVGElement) => + (kvp: CSSDeclaration) => + Object.entries(kvp ?? {}).forEach(([k, v]) => asap(getCSSPropertySetter(node.style, k as keyof CSSDeclaration), v)) +; - ; -}; diff --git a/src/sinks/subtree-sink.ts b/src/sinks/subtree-sink.ts new file mode 100644 index 0000000..f7470d1 --- /dev/null +++ b/src/sinks/subtree-sink.ts @@ -0,0 +1,34 @@ +import type { DOMSubtreeObject, RMLTemplateExpressions, SinkBindingConfiguration } from "../types/internal"; +import type { HTMLContainerElement } from "../types/dom"; +import type { Sink, ExplicitSink } from "../types/sink"; + +import { AttributeObjectSink } from "./attribute-sink"; + +import { SINK_TAG } from "../constants"; + + +export const SubtreeSink: Sink = + (node: HTMLContainerElement) => + (subtreeData: DOMSubtreeObject) => { + Object.entries(subtreeData) + .forEach(([k, v]) => + [...node.querySelectorAll(k)].forEach((e: Element) => + AttributeObjectSink(e as HTMLContainerElement)(v) + ) + ); + }; + +/** + * A specialised sink to deep-merge a DOM subtree into the target element + * @param source A present or future DOM Subtree Object + * @returns RMLTemplateExpression A template expression for the "checked" attribute + * @example
${Subtree(subtreeObject)}
+ */ +export const Subtree: ExplicitSink<'subtree'> = + (source: RMLTemplateExpressions.Any) => ({ + type: SINK_TAG, + t: 'subtree', + source, + sink: SubtreeSink, + }) as SinkBindingConfiguration +; diff --git a/src/sources/checked-source.test.ts b/src/sources/checked-source.test.ts new file mode 100644 index 0000000..145a7ce --- /dev/null +++ b/src/sources/checked-source.test.ts @@ -0,0 +1,52 @@ +import { Observable, Subject } from 'rxjs'; +import { MockElement, MockEvent } from '../test-support'; +import { CheckedState, checkedState } from './checked-source'; + +describe('CheckedState Event Adapter', () => { + + it('Emites the checked state of an element into a target', () => { + const value = true; + + const el = MockElement({ + tagName: 'INPUT', + type: 'checkbox', + checked: value, + }); + + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + + const handlerSpy = jest.fn(); + const source = CheckedState(handlerSpy); + source.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(value); + }); + +}); + + +describe('checkedState Event Operator', () => { + + it('Emites the checked state of an element into a target', () => { + const value = true; + const el = MockElement({ + tagName: 'INPUT', + type: 'checkbox', + checked: value, + }); + + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + + const handlerSpy = jest.fn(); + const pipeline = new Subject().pipe(checkedState) as Observable & Subject; + pipeline.subscribe(x=>handlerSpy(x)); + pipeline.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(value); + }); + +}); diff --git a/src/sources/checked-source.ts b/src/sources/checked-source.ts new file mode 100644 index 0000000..9f58962 --- /dev/null +++ b/src/sources/checked-source.ts @@ -0,0 +1,19 @@ +import { map } from "rxjs"; +import { inputPipe } from '../utils/input-pipe'; + +/** + * An Event Source Operator emitting the checked state of the underlying checkbox element instead of a regular DOM Event object + * @returns OperatorFunction + */ +export const checkedState = map((e: Event) => (e.target).checked); + + +/** + * An Event Source emitting the checked state of the underlying checkbox element instead of a regular DOM Event object + * @param handler A handler function or observer to send events to + * @returns EventSource + */ +export const CheckedState = inputPipe( + checkedState +); + diff --git a/src/sources/cut-source.test.ts b/src/sources/cut-source.test.ts new file mode 100644 index 0000000..628331b --- /dev/null +++ b/src/sources/cut-source.test.ts @@ -0,0 +1,54 @@ +import { Observable, Subject } from 'rxjs'; +import { MockElement, MockEvent } from '../test-support'; +import { Cut, cut } from './cut-source'; + +describe('Cut Event Adapter', () => { + + it('Cuts a value from an element into a target', () => { + const oldValue = 'old data'; + + const el = MockElement({ + tagName: 'INPUT', + type: 'text', + value: oldValue, + }); + + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + + const handlerSpy = jest.fn(); + const source = Cut(handlerSpy); + source.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(oldValue); + expect(el.value).toEqual(''); + }); + +}); + +describe('cut Event Operator', () => { + + it('Cuts and emits a value from an element', () => { + const oldValue = 'old data'; + + const el = MockElement({ + tagName: 'INPUT', + type: 'text', + value: oldValue, + }); + + const eventData = MockEvent('input', { + target: el as HTMLInputElement + }); + + const handlerSpy = jest.fn(); + const pipeline = new Subject().pipe(cut) as Observable & Subject; + pipeline.subscribe(x=>handlerSpy(x)); + pipeline.next(eventData); + + expect(handlerSpy).toHaveBeenCalledWith(oldValue); + expect(el.value).toEqual(''); + }); + +}); diff --git a/src/sources/cut-source.ts b/src/sources/cut-source.ts index fea2586..8b9a47f 100644 --- a/src/sources/cut-source.ts +++ b/src/sources/cut-source.ts @@ -1,33 +1,30 @@ -import type { RMLTemplateExpressions } from '../types/internal'; -import type { Observer } from '../types/futures'; -import type { Source } from '../types/source'; - import { map } from "rxjs"; -import { inputPipe } from '../utils/input-pipe'; import { autoValue } from '../utils/auto-value'; +import { inputPipe } from '../utils/input-pipe'; /** - * An Event Source that "cuts" the value of the underlying element - * @param handler A handler function or observer to send events to + * An Event operator that "cuts" the value of the underlying element + * This operator has side effects, as it will directly modify the underlying element + * @param target A target function or observer to send events to * @returns EventSource */ -export const Cut = - - (source?: RMLTemplateExpressions.SourceExpression): Source | Observer | EventListenerFunction => { - const handler = inputPipe( - map((e: I) => { - const t = (e.target); - const v = autoValue(t); - t.value = ''; - return v - }) - ); - - return ( - source - ? handler(source) - : handler - ); - } +export const cut = + map((e: I): O => { + const t = (e.target); + const v = autoValue(t); + (t as HTMLInputElement).value = ''; + // TODO: t.innerText = '' for contenteditable items? + return v + }) ; +/** + * An Event Adapter that "cuts" the value of the underlying element + * @param target A target function or observer to send events to + * @returns EventSource + */ +export const Cut = + inputPipe( + cut + ) +; \ No newline at end of file diff --git a/src/sources/dataset-source.ts b/src/sources/dataset-source.ts index 1d7ae3c..5daad31 100644 --- a/src/sources/dataset-source.ts +++ b/src/sources/dataset-source.ts @@ -5,28 +5,45 @@ import type { Observer } from "../types/futures"; import { map } from 'rxjs'; import { inputPipe } from '../utils/input-pipe'; import { Source } from "../types/source"; +import { curry } from "../utils/curry"; + +/** + * An Event Source Operator emitting any dataset value from the underlying element instead of a regular DOM Event object + * @returns OperatorFunction + * @example +**/ +export const dataset = (key: string) => + map((e: E) => ((e.target).dataset[key])); /** * An Event Source emitting any dataset value from the underlying element instead of a regular DOM Event object * @param handler A handler function or observer to send events to * @returns EventSource - * @example - * @example - * @example - * @example + * @example + * @example **/ export const Dataset = - (key: string, source?: RMLTemplateExpressions.SourceExpression): Source | Observer | EventListenerFunction => { - const handler = inputPipe( - map((e: I) => ((e.target).dataset[key])) - ); - - return ( - source - ? handler(source) - : handler - ); - } + (key: string, source?: RMLTemplateExpressions.SourceExpression) => + 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 + * @example +**/ +export const datasetObject = map((e: Event) => ((e.target).dataset)); + +/** + * An Event Source emitting the full dataset object from the underlying element instead of a regular DOM Event object + * @param handler A handler function or observer to send events to + * @returns EventSource + * @example + * @example +**/ +export const DatasetObject = + + (source?: RMLTemplateExpressions.SourceExpression) => + curry(datasetObject, source) +; diff --git a/src/sources/event-data.ts b/src/sources/event-data.ts index a3a6480..7651891 100644 --- a/src/sources/event-data.ts +++ b/src/sources/event-data.ts @@ -1,6 +1,20 @@ import { inputPipe } from '../utils/input-pipe'; import { map } from 'rxjs'; -export const EventData = inputPipe( - map(e => e.data) -); +/** + * An Event Operator that emits the value of the underlying element + * @param target A target function or observer to send events to + * @returns EventSource + **/ +export const eventData = map((e: InputEvent) => e.data); + +/** + * An Event Adapter that emits the value of the underlying element + * @param target A target function or observer to send events to + * @returns EventSource + **/ +export const EventData = + inputPipe( + eventData + ) +; diff --git a/src/sources/form-data-source.ts b/src/sources/form-data-source.ts index 9a1c4e3..9d4f57a 100644 --- a/src/sources/form-data-source.ts +++ b/src/sources/form-data-source.ts @@ -2,15 +2,19 @@ import { inputPipe } from '../utils/input-pipe'; import { map, tap } from 'rxjs'; /** - * An Event Source emitting a FormData object from the underlying form element instead of a regular DOM Event object + * An Event Operator emitting a FormData object from the underlying form element instead of a regular DOM Event object + * @returns OperatorFunction + * @example
...
+**/ +export const form= map((e: Event) => Object.fromEntries(new FormData(e.currentTarget))); + +/** + * An Event Adapter emitting a FormData object from the underlying form element instead of a regular DOM Event object * @param handler A handler function or observer to send events to * @returns EventSource - * @example
...
- * @example
...
- * @example
...
- * @example
...
+ * @example
...
+ * @example
...
**/ -export const Form = inputPipe( - tap(e => e.preventDefault()), - map((e: FormDataEvent) => Object.fromEntries(new FormData(e.target))), +export const Form = inputPipe( + form, ); diff --git a/src/sources/keyboard-source.ts b/src/sources/keyboard-source.ts index ca1deac..ea916d6 100644 --- a/src/sources/keyboard-source.ts +++ b/src/sources/keyboard-source.ts @@ -3,10 +3,16 @@ import { map } from 'rxjs'; import { inputPipe } from '../utils/input-pipe'; /** - * An Event Source emitting the "[event.clientX, event.clientY]" mouse coordinates + * An Event Operator emitting event.key instead of any KeyboardEvent object + * @returns OperatorFunction + */ +export const key = map((e: KeyboardEvent) => e.key); + +/** + * An Event Adapter emitting event.key instead of any KeyboardEvent object * @param handler A handler function or observer to send events to * @returns EventSource */ export const Key = inputPipe( - map((e: KeyboardEvent) => e.key) + key ); diff --git a/src/sources/last-touch-xy-source.ts b/src/sources/last-touch-xy-source.ts index 0f4e83e..1575f89 100644 --- a/src/sources/last-touch-xy-source.ts +++ b/src/sources/last-touch-xy-source.ts @@ -4,13 +4,20 @@ import { map } from "rxjs"; import { inputPipe } from "../utils/input-pipe"; /** - * An Event Source emitting the "[x, y]" coordinates of the last touch - * @returns EventSource + * An Event Operator emitting the "[x, y]" coordinates of the last touch event + * @returns OperatorFunction */ -export const LastTouchXY = inputPipe( +export const lastTouchXY = map((e: TouchEvent) => { 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 +*/ +export const LastTouchXY = inputPipe( + lastTouchXY +); \ No newline at end of file diff --git a/src/sources/numberset-source.ts b/src/sources/numberset-source.ts index 9f51261..404ff2b 100644 --- a/src/sources/numberset-source.ts +++ b/src/sources/numberset-source.ts @@ -1,23 +1,23 @@ -import type { RMLTemplateExpressions } from "../types/internal"; -import { EventListenerFunction } from "../types/dom"; -import type { Observer } from "../types/futures"; - import { map } from 'rxjs'; import { inputPipe } from '../utils/input-pipe'; import { Source } from "../types/source"; +/** + * An Event Operator emitting a numerical dataset value from the underlying element instead of a regular DOM Event object + * @returns OperatorFunction + * @example +**/ +export const numberset = (key: string) => map((e: Event) => Number((e.target).dataset[key])); + /** * An Event Source emitting a numerical dataset value from the underlying element instead of a regular DOM Event object * @param handler A handler function or observer to send events to - * @returns EventSource - * @example - * @example - * @example - * @example + * @returns EventSource + * @example + * @example **/ export const Numberset = (key: string): Source => inputPipe( - map((e: Event) => Number((e.target).dataset[key])) + numberset(key) ) -; - +; \ No newline at end of file diff --git a/src/sources/object-source.test.ts b/src/sources/object-source.test.ts index 4144dcf..03f4540 100644 --- a/src/sources/object-source.test.ts +++ b/src/sources/object-source.test.ts @@ -1,14 +1,13 @@ import { MockElement, MockEvent } from '../test-support'; -import { isObjectSource, ObjectSource, ObjectSourceExpression } from './object-source'; +import { isObjectSource, ObjectSource } from './object-source'; describe('Object Source', () => { - describe('Given a [target, attribute] pair', () => { + describe('Given a [attribute, target] pair', () => { it('Creates an Object Source', () => { const object: any = {}; const testAttribute = 'someAttribute'; - const data = >[object, testAttribute]; const newData = 'new value'; const el = MockElement({ @@ -20,7 +19,7 @@ describe('Object Source', () => { target: el as HTMLInputElement }); - const source = ObjectSource(data).bind(el); + const source = ObjectSource(testAttribute, object).bind(el); source(eventData); expect(object[testAttribute]).toEqual(newData); @@ -29,8 +28,7 @@ describe('Object Source', () => { it('Creates an Array Source', () => { const arr = [0, 1, 2, 3, 4]; const testAttribute = 2; - const data = >[arr, testAttribute]; - const newData = 'new value'; + const newData = 'new stuff'; const el = MockElement({ tagName: 'INPUT', @@ -42,8 +40,9 @@ describe('Object Source', () => { target: el as HTMLInputElement }); - const source = ObjectSource(data).bind(el); + const source = ObjectSource(testAttribute, arr).bind(el); + // @ts-ignore source(eventData); expect(arr[testAttribute]).toEqual(newData); }); @@ -53,7 +52,6 @@ describe('Object Source', () => { it('Creates a boolean Object Source', () => { const object: any = {}; const testAttribute = 'someAttribute'; - const data = >[object, testAttribute]; const newData = true; const el = MockElement({ @@ -65,7 +63,7 @@ describe('Object Source', () => { target: el as HTMLInputElement }); - const source = ObjectSource(data).bind(el); + const source = ObjectSource(testAttribute, object).bind(el); source(eventData); expect(object[testAttribute]).toEqual(newData); @@ -78,7 +76,6 @@ describe('Object Source', () => { it('Creates an Object Source from its content', () => { const object: any = {}; const testAttribute = 'someAttribute'; - const data = >[object, testAttribute]; const newData = 'some content'; const el = MockElement({ @@ -89,7 +86,7 @@ describe('Object Source', () => { target: el }); - const source = ObjectSource(data).bind(el); + const source = ObjectSource(testAttribute, object).bind(el); source(eventData); expect(object[testAttribute]).toEqual(newData); diff --git a/src/sources/object-source.ts b/src/sources/object-source.ts index 7fa391a..a561b9f 100644 --- a/src/sources/object-source.ts +++ b/src/sources/object-source.ts @@ -1,10 +1,16 @@ import type { EventListenerFunction } from "../types/dom"; import type { RMLTemplateExpression } from "../types/internal"; +import type { Source } from '../types/source'; +import { autoValue } from '../utils/auto-value'; + +export type ObjectKey = string | number | symbol; export type TargetObject = object | Array; // Record; -export type ObjectSourceExpression = [target: T, key: keyof T]; +export type ObjectSourceExpression = [key: keyof T, target: T]; -export const isObjectSource = (expression: RMLTemplateExpression): expression is ObjectSourceExpression => +export const isObjectSource = + + (expression: RMLTemplateExpression): expression is ObjectSourceExpression => Array.isArray(expression) && expression.length == 2; /** @@ -13,26 +19,33 @@ export const isObjectSource = (expression: RMLTemplateEx * @param expression an [object, 'property'] or [array, index] pair to update * @returns A data source * @example - * @example - * @example + * @example + * @example */ -export const ObjectSource = (expression: ObjectSourceExpression) => - >((e: E) => { - // Only elements are supported for this source - const t = (e.target) - const valueSource = t.type == 'checkbox' ? t.checked : t.tagName == 'INPUT' ? t.value : t.innerText; - const [target, key] = <[Record, string] | [Array, number]>expression; - (target as any)[key] = valueSource; - }) +export const ObjectSource = + + (key: ObjectKey, targetObject?: T) => { + const handler = ((targetObject: T, e: E) => { + const t = e.target as HTMLInputElement; + (targetObject as any)[key] = autoValue(t); + }); + + return (targetObject + ? handler.bind(null, targetObject as T) as EventListenerFunction + : (t2: T)=>(handler.bind(null, t2) as EventListenerFunction) + ); + } ; /** * A data source that updates an object's property from an element when * a certain event occurs - * @param object The object to update * @param property A property to update in the given object - * @returns An event handler stream + * @param object The object to update + * @returns An event handler */ -export const Update = (object: T, property: keyof T): EventListenerFunction => - ObjectSource([object, property]) +export const Update = + + (property: ObjectKey, object?: T): ((t2: T) => EventListenerFunction) | EventListenerFunction | Source => + ObjectSource(property, object) ; diff --git a/src/sources/offset-xy-source.ts b/src/sources/offset-xy-source.ts index 5870171..eede360 100644 --- a/src/sources/offset-xy-source.ts +++ b/src/sources/offset-xy-source.ts @@ -4,10 +4,15 @@ import { map } from "rxjs"; import { inputPipe } from "../utils/input-pipe"; /** - * An Event Source emitting the "[event.offsetX, event.offsetY]" coordinates + * An Event Source Operator emitting the "[event.offsetX, event.offsetY]" coordinates + * @returns OperatorFunction +*/ +export const offsetXY = map((e: PointerEvent) => [e.offsetX, e.offsetY]); + +/** + * An Event Adapter emitting the "[event.offsetX, event.offsetY]" coordinates * @returns EventSource */ export const OffsetXY = inputPipe( - map((e: PointerEvent) => [e.offsetX, e.offsetY]) -); - + offsetXY +); \ No newline at end of file diff --git a/src/sources/swap-source.ts b/src/sources/swap-source.ts new file mode 100644 index 0000000..af0862a --- /dev/null +++ b/src/sources/swap-source.ts @@ -0,0 +1,34 @@ +import type { RMLTemplateExpressions } from '../types/internal'; +import type { Observer } from '../types/futures'; +import type { Source } from '../types/source'; + +import { map } from "rxjs"; +import { curry } from '../utils/curry'; +import { EventListenerFunction } from '../types/dom'; + +/** + * An Event Source Operator that "cuts" the value of the underlying element + * and resets it to the provided value or empty otherwise + * @param handler A handler function or observer to send events to + * @returns EventSource + */ +export const swap = (replacement: string | Function) => + map((e: 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 element + * and resets it to the provided value or empty otherwise + * @param handler A handler function or observer to send events to + * @returns EventSource + */ +export const Swap = + + (replacement: string | Function = '', source?: RMLTemplateExpressions.SourceExpression) => + curry(swap(replacement), source) +; diff --git a/src/sources/value-source.ts b/src/sources/value-source.ts index f2c6b16..e9db7db 100644 --- a/src/sources/value-source.ts +++ b/src/sources/value-source.ts @@ -1,29 +1,65 @@ import { map } from "rxjs"; import { inputPipe } from '../utils/input-pipe'; +import { autoValue } from "../utils/auto-value"; /** - * An Event Source emitting the value of the underlying element instead of a regular DOM Event object + * An Event Source Operator emitting the value of the underlying element instead of a regular DOM Event object + * @returns OperatorFunction + */ +export const value = map((e: Event) => autoValue((e.target))); + +/** + * An Event Adapter emitting the value of the underlying element instead of a regular DOM Event object * @param handler A handler function or observer to send events to * @returns EventSource */ export const Value = inputPipe( - map(e => (e.target).value) + value +); + +/** + * An Event Source Operator emitting the value of the underlying element instead of a regular DOM Event object + * @returns OperatorFunction + */ +export const valueAsString = map((e: Event) => (e.target).value); + +/** + * An Event Adapter emitting the value of the underlying element instead of a regular DOM Event object + * @param handler A handler function or observer to send events to + * @returns EventSource + */ +export const ValueAsString = inputPipe( + valueAsString ); +/** + * An Event Source Operator for valueAsNumber + * @description Emits the numeric value of the underlying or instead of a regular DOM Event object + * @returns OperatorFunction + */ +export const valueAsNumber = map((e: Event) => (e.target).valueAsNumber); + /** * An Event Source for valueAsNumber * @description Emits the numeric value of the underlying or instead of a regular DOM Event object - * @returns EventSource + * @returns EventSource */ export const ValueAsNumber = inputPipe( - map(e => (e.target).valueAsNumber) + valueAsNumber ); /** - * An Event Source for valueAsDate + * An Event Source Operator for valueAsDate * @description Emits the numeric value of the underlying `` instead of a regular DOM Event object - * @returns EventSource + * @returns OperatorFunction + */ +export const valueAsDate = map((e: Event) => (e.target).valueAsDate); + +/** + * An Event Adapter for valueAsDate + * @description Emits the numeric value of the underlying `` instead of a regular DOM Event object + * @returns EventSource */ export const ValueAsDate = inputPipe( - map(e => (e.target).valueAsDate) + valueAsDate ); diff --git a/src/types/dom-observable.d.ts b/src/types/dom-observable.d.ts index 1a188fd..1bc6c3c 100644 --- a/src/types/dom-observable.d.ts +++ b/src/types/dom-observable.d.ts @@ -1,20 +1,20 @@ import type { RMLEventMap } from './dom'; interface Observable { - subscribe: (observer: (value: T) => void) => void; + subscribe: (observer: (e: T) => void) => void; } declare global { interface Document { - when(event: keyof RMLEventMap): Observable; + when(event: keyof RMLEventMap): Observable; } interface Element { - when(event: keyof RMLEventMap): Observable; + when(event: keyof RMLEventMap): Observable; } interface Window { - when(event: keyof RMLEventMap): Observable; + when(event: keyof RMLEventMap): Observable; } } diff --git a/src/types/internal.ts b/src/types/internal.ts index 1e82f71..cf2a33c 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -52,7 +52,7 @@ export const isSourceExpression = (e: unknown): e is RMLTemplateExpressions.S * An Object's property or an Array's index */ export type property = string | number | symbol; - +export type QuerySelectorString = string; export type SourceAttributeName = RMLEventAttributeName; export type SinkAttributeName = T extends RMLEventAttributeName ? never : string; export type AttributeName = SourceAttributeName | SinkAttributeName; @@ -63,7 +63,9 @@ export type AttributeValue = SinkAttributeValue | SourceAttributeValue; export type AttributeObject = { [K in string]: K extends SourceAttributeName ? SourceAttributeValue : SinkAttributeValue; }; - +export type DOMSubtreeObject = { + [K in QuerySelectorString]: AttributeObject; +} export namespace RMLTemplateExpressions { /** diff --git a/src/types/sink.ts b/src/types/sink.ts index 337d01c..65d9db7 100644 --- a/src/types/sink.ts +++ b/src/types/sink.ts @@ -1,8 +1,8 @@ import type { CSSClassName } from "./style"; import type { MaybeFuture } from "./futures"; -import type { HTMLString } from './dom'; +import type { HTMLContainerElement, HTMLString } from './dom'; import type { FocusableElement } from './rml'; -import { AttributeObject, SinkBindingConfiguration } from "./internal"; +import { AttributeObject, DOMSubtreeObject, SinkBindingConfiguration } from "./internal"; /** * The mounted part of a Sink who performs the actual work, e.g.: DOM updates, console.logs, etc. @@ -99,6 +99,10 @@ export type SinkElementTypes = { elements: HTMLElement | SVGElement | MathMLElement; types: boolean | 'true' | 'removed'; }; + 'subtree': { + elements: HTMLContainerElement | SVGElement | MathMLElement; + types: DOMSubtreeObject; + }; 'text': { elements: HTMLElement; types: string | number; diff --git a/src/utils/auto-value.ts b/src/utils/auto-value.ts index a655840..cbdc6ea 100644 --- a/src/utils/auto-value.ts +++ b/src/utils/auto-value.ts @@ -1,10 +1,17 @@ /** - * Get the value of an element matching its type: number, date or string. + * Get the value of an element matching its type: number, date or string, + * or innerText if it's any other contenteditable element **/ -export const autoValue = (input: HTMLInputElement) => - input.type=="number" - ? input.valueAsNumber : - input.type == "date" - ? input.valueAsDate : - input.value +export const autoValue = (input: HTMLInputElement | HTMLElement) => + (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 ; diff --git a/src/utils/curry.ts b/src/utils/curry.ts new file mode 100644 index 0000000..6fabf8d --- /dev/null +++ b/src/utils/curry.ts @@ -0,0 +1,16 @@ +import type { RMLTemplateExpressions } from '../types/internal'; +import type { OperatorFunction } from 'rxjs'; + +import { inputPipe, pipeIn } from '../utils/input-pipe'; + +/** + * Curry for stream operators + **/ +export const curry = + + (op: OperatorFunction, destination: RMLTemplateExpressions.Any) => + destination + ? pipeIn(destination, op) + : inputPipe(op) +; + diff --git a/src/utils/input-pipe.ts b/src/utils/input-pipe.ts index a7ac965..de1233a 100644 --- a/src/utils/input-pipe.ts +++ b/src/utils/input-pipe.ts @@ -43,6 +43,8 @@ export const inputPipe = (...pipeline: OperatorPipeline) => export const feed = pipeIn; export const feedIn = pipeIn; +export const reversePipe = inputPipe; + // WIP, TBC export const source = (...reversePipeline: [...OperatorPipeline, Observer]) => pipeIn(>reversePipeline.pop(), ...>reversePipeline); export const sink = (source: MaybeFuture, ...pipeline: OperatorPipeline) => source.pipe(...pipeline); diff --git a/tsconfig.base.json b/tsconfig.base.json index 9ffeaf4..94a6479 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2,7 +2,7 @@ "compilerOptions": { "allowJs": true, "moduleResolution": "node", - "lib": [ "ESNext", "DOM", "DOM.Iterable" ], + "lib": [ "ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable" ], "module": "ESNext", "target": "ESNext", "types": [ "node", "jest" ], @@ -11,7 +11,8 @@ }, "strict": true, "sourceMap": true, - "removeComments": true, + "declarationMap": true, + "removeComments": false, "resolveJsonModule": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true,