├── .editorconfig ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .stackblitzrc ├── CNAME ├── README.md ├── benchmarks ├── view-rendering-2 │ ├── index.html │ └── index.js └── view-rendering │ ├── index.html │ └── index.js ├── docs ├── .gitignore ├── 404.html ├── CNAME ├── Gemfile ├── Gemfile.lock ├── _config.yml ├── _posts │ └── _test.md.xxx ├── assets │ ├── css │ │ └── style.scss │ ├── event-adapters.png │ ├── github-hero-2.png │ ├── how-rimmel-testing-works.png │ ├── how-rimmel-works-3.png │ ├── how-rimmel-works-4.png │ ├── how-rimmel-works-5.png │ ├── how-rimmel-works-6.png │ ├── how-rimmel-works-7.png │ ├── how-rimmel-works-9.png │ ├── how-rimmel-works.png │ ├── isolating-side-effects.png │ ├── observable-types.png │ ├── rimmel.png │ └── try-it-button.png ├── docs │ └── assets ├── favicon.png └── index.md ├── examples └── kitchen-sink │ ├── flower.webm │ ├── index.html │ └── index.ts ├── favicon.svg ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.dts.js ├── rollup.config.js ├── server.sh ├── src ├── constants.ts ├── custom-element.ts ├── debug.ts ├── definitions │ ├── boolean-attributes.ts │ ├── enumerated-attributes.ts │ └── non-bubbling-events.ts ├── index.ts ├── internal-state.ts ├── lib │ ├── addListener.ts │ ├── drain.ts │ └── observature.ts ├── lifecycle │ └── data-binding.ts ├── parser │ ├── parser.test.ts │ ├── parser.ts │ ├── sink-map.ts │ └── ssr.ts ├── schedulers │ ├── adaptive-animation-frame.ts │ ├── animation-frame-by-node.ts │ ├── animation-frame.ts │ └── ema-animation-frame.ts ├── sinks │ ├── any-sink.ts │ ├── append-html-sink.test.ts │ ├── append-html-sink.ts │ ├── attribute-sink.test.ts │ ├── attribute-sink.ts │ ├── blur-sink.test-d.ts │ ├── blur-sink.test.ts │ ├── blur-sink.ts │ ├── checked-sink.test.ts │ ├── checked-sink.ts │ ├── class-sink.test.ts │ ├── class-sink.ts │ ├── closed-sink.ts │ ├── content-sink.ts │ ├── dataset-sink.test.ts │ ├── dataset-sink.ts │ ├── disabled-sink.test.ts │ ├── disabled-sink.ts │ ├── error-sink.test.ts │ ├── error-sink.ts │ ├── event-handler-sink.ts │ ├── focus-sink.test.ts │ ├── focus-sink.ts │ ├── hidden-sink.ts │ ├── index.ts │ ├── inner-html-sink.test.ts │ ├── inner-html-sink.ts │ ├── inner-text-sink.test.ts │ ├── inner-text-sink.ts │ ├── json-dump-sink.test.ts │ ├── json-dump-sink.ts │ ├── mixin-sink.ts │ ├── prepend-html-sink.test.ts │ ├── prepend-html-sink.ts │ ├── readonly-sink.test.ts │ ├── readonly-sink.ts │ ├── removed-sink.test.ts │ ├── removed-sink.ts │ ├── sanitize-html-sink.test.ts │ ├── sanitize-html-sink.ts │ ├── selected-index-sink.test.ts │ ├── selected-index-sink.ts │ ├── signal-sink.ts │ ├── style-sink.test.ts │ ├── style-sink.ts │ ├── subtree-sink.ts │ ├── suspense-sink.ts │ ├── termination-sink.ts │ ├── text-content-sink.test.ts │ ├── text-content-sink.ts │ ├── value-sink.test.ts │ ├── value-sink.ts │ └── writable-stream.ts ├── sources │ ├── all-source.ts │ ├── as-latest-from.ts │ ├── autoform-source.ts │ ├── checked-source.test.ts │ ├── checked-source.ts │ ├── client-xy-source.test.ts │ ├── client-xy-source.ts │ ├── cut-source.test.ts │ ├── cut-source.ts │ ├── dataset-source.test.ts │ ├── dataset-source.ts │ ├── event-data.ts │ ├── event-listener-object-source.ts │ ├── event-listener.test.ts │ ├── event-listener.ts │ ├── event-target.ts │ ├── first-touch-xy-source.ts │ ├── form-data-source.ts │ ├── keyboard-source.ts │ ├── last-touch-xy-source.ts │ ├── modifiers │ │ ├── active.ts │ │ └── passive.ts │ ├── numberset-source.ts │ ├── object-source.test.ts │ ├── object-source.ts │ ├── observer-source.ts │ ├── offset-xy-source.ts │ ├── readable-stream.ts │ ├── swap-source.ts │ ├── value-source.test.ts │ └── value-source.ts ├── ssr │ ├── hydration.ts │ └── index.ts ├── test-support.ts ├── types │ ├── attribute.ts │ ├── basic.ts │ ├── class.ts │ ├── constructs.ts │ ├── content.ts │ ├── coords.ts │ ├── dataset.ts │ ├── dom-observable.d.ts │ ├── dom.ts │ ├── event-listener.ts │ ├── futures.ts │ ├── index.ts │ ├── internal.ts │ ├── json.ts │ ├── monkey-patched-observable.ts │ ├── rml.ts │ ├── schedulers.ts │ ├── sink.ts │ ├── source.ts │ ├── style.ts │ └── value.ts └── utils │ ├── auto-value.ts │ ├── camelCase.ts │ ├── curry.ts │ ├── input-pipe.ts │ ├── is-behavior.ts │ ├── is-function.ts │ ├── suspense.ts │ ├── take-first-sync.ts │ └── to-listener.ts ├── tsconfig.json ├── typedoc.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | tab_width = 2 6 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Handbook and Website 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build-and-deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: 22 20 | 21 | - name: Install dependencies 22 | run: npm ci # Or the appropriate command to install dependencies 23 | 24 | - name: Build the library 25 | run: npm run build 26 | 27 | - name: Build the site 28 | run: npm run website 29 | 30 | - name: Build the handbook 31 | run: npm run handbook 32 | 33 | - name: Deploy to GitHub Pages 34 | uses: peaceiris/actions-gh-pages@v3 35 | with: 36 | github_token: ${{ secrets.GITHUB_TOKEN }} 37 | publish_dir: ./dist 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | docs 3 | dist 4 | 5 | .vscode 6 | bundle-stats* 7 | 8 | -------------------------------------------------------------------------------- /.stackblitzrc: -------------------------------------------------------------------------------- 1 | { 2 | "startCommand": "npm run kitchen-sink" 3 | } 4 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | rimmel.js.org -------------------------------------------------------------------------------- /benchmarks/view-rendering-2/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /benchmarks/view-rendering-2/index.js: -------------------------------------------------------------------------------- 1 | import { rml } from '../../dist/rimmel.es.js'; 2 | const { Subject, BehaviorSubject } = rxjs; 3 | const { map, scan } = rxjs.operators 4 | 5 | const view1 = () => { 6 | const counter = (new BehaviorSubject(0)).pipe( 7 | scan(a=>a+1) 8 | ) 9 | return rml` 10 |

View 1

11 |

This is view1. It does some stuff, etc

12 |

This is view1. It does some stuff, etc

13 |

This is view1. It does some stuff, etc

14 | 15 | You clicked the button ${counter} times. 16 | ` 17 | } 18 | 19 | const view2 = () => { 20 | const counter = (new BehaviorSubject(0)).pipe( 21 | scan(a=>a+1) 22 | ) 23 | return rml` 24 |

View 2

25 | 26 |

This is view2. It does some more stuff, etc

27 |

This is view2. It does , etc

28 |

This is view2. It does some more stuff, etc

29 | You clicked the button ${counter} times. 30 | ` 31 | } 32 | 33 | function run() { 34 | const currentView = new Subject() 35 | const views = [view1, view2] 36 | const renderCount = currentView.pipe( 37 | map(x=>1), 38 | scan(a=>a+1), 39 | ) 40 | 41 | document.body.innerHTML = rml` 42 | render view 1 43 | render view 2 44 |
45 | ${[...Array(20).fill(0)].map((x,i)=>rml` 46 | X ${renderCount} 47 | `)} 48 | ${[...Array(20).fill(0)].map((x,i)=>rml` 49 | Y ${renderCount} 50 | `)} 51 | 52 |
53 | ${currentView} 54 |
55 |
You changed view ${renderCount} times. 56 | ` 57 | } 58 | 59 | run() 60 | 61 | -------------------------------------------------------------------------------- /benchmarks/view-rendering/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /benchmarks/view-rendering/index.js: -------------------------------------------------------------------------------- 1 | import { rml } from '../../dist/rimmel.es.js'; 2 | const { Subject, BehaviorSubject } = rxjs; 3 | const { map, scan } = rxjs.operators 4 | 5 | const view1 = () => { 6 | const counter = (new BehaviorSubject(0)).pipe( 7 | scan(a=>a+1) 8 | ) 9 | return rml` 10 |

View 1

11 |

This is view1. It does some stuff, etc

12 |

This is view1. It does some stuff, etc

13 |

This is view1. It does some stuff, etc

14 | 15 | You clicked the button ${counter} times. 16 | ` 17 | } 18 | 19 | const view2 = () => { 20 | const counter = (new BehaviorSubject(0)).pipe( 21 | scan(a=>a+1) 22 | ) 23 | return rml` 24 |

View 2

25 | 26 |

This is view2. It does some more stuff, etc

27 |

This is view2. It does , etc

28 |

This is view2. It does some more stuff, etc

29 | You clicked the button ${counter} times. 30 | ` 31 | } 32 | 33 | function run() { 34 | const currentView = new Subject() 35 | const views = [view1, view2] 36 | const renderCount = currentView.pipe( 37 | map(x=>1), 38 | scan(a=>a+1), 39 | ) 40 | 41 | let idx = 0 42 | 43 | const changeView = () => { 44 | currentView.next(views[idx]) 45 | idx = (idx+1)%views.length 46 | requestAnimationFrame(changeView) 47 | } 48 | 49 | document.body.innerHTML = rml` 50 | render view 1 51 | render view 2 52 |
53 | ${[...Array(20).fill(0)].map((x,i)=>rml` 54 | X ${renderCount} 55 | `)} 56 | ${[...Array(20).fill(0)].map((x,i)=>rml` 57 | Y ${renderCount} 58 | `)} 59 | 60 |
61 | ${currentView} 62 |
63 |
You changed view ${renderCount} times. 64 | ` 65 | 66 | changeView() 67 | } 68 | 69 | run() 70 | 71 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _site 2 | .sass-cache 3 | .jekyll-cache 4 | .jekyll-metadata 5 | vendor 6 | -------------------------------------------------------------------------------- /docs/404.html: -------------------------------------------------------------------------------- 1 | --- 2 | permalink: /404.html 3 | layout: default 4 | --- 5 | 6 | 19 | 20 |
21 |

404

22 | 23 |

Page not found :(

24 |

The requested page could not be found.

25 |
26 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | rimmel.js.org -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | # Hello! This is where you manage which Jekyll version is used to run. 3 | # When you want to use a different version, change it below, save the 4 | # file and run `bundle install`. Run Jekyll with `bundle exec`, like so: 5 | # 6 | # bundle exec jekyll serve 7 | # 8 | # This will help ensure the proper Jekyll version is running. 9 | # Happy Jekylling! 10 | # gem "jekyll", "~> 4.3.3" 11 | 12 | gem "github-pages", "~> 228", group: :jekyll_plugins 13 | gem 'jekyll-theme-leap-day', '~> 0.2.0' 14 | 15 | # If you want to use GitHub Pages, remove the "gem "jekyll"" above and 16 | # uncomment the line below. To upgrade, run `bundle update github-pages`. 17 | # gem "github-pages", group: :jekyll_plugins 18 | # If you have any plugins, put them here! 19 | group :jekyll_plugins do 20 | gem "jekyll-feed", "~> 0.12" 21 | end 22 | 23 | # Windows and JRuby does not include zoneinfo files, so bundle the tzinfo-data gem 24 | # and associated library. 25 | platforms :mingw, :x64_mingw, :mswin, :jruby do 26 | gem "tzinfo", ">= 1", "< 3" 27 | gem "tzinfo-data" 28 | end 29 | 30 | # Performance-booster for watching directories on Windows 31 | gem "wdm", "~> 0.1.1", :platforms => [:mingw, :x64_mingw, :mswin] 32 | 33 | # Lock `http_parser.rb` gem to `v0.6.x` on JRuby builds since newer versions of the gem 34 | # do not have a Java counterpart. 35 | gem "http_parser.rb", "~> 0.6.0", :platforms => [:jruby] 36 | 37 | gem "webrick", "~> 1.8" 38 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | # Welcome to Jekyll! 2 | # 3 | # This config file is meant for settings that affect your whole blog, values 4 | # which you are expected to set up once and rarely edit after that. If you find 5 | # yourself editing this file very often, consider using Jekyll's data files 6 | # feature for the data you need to update frequently. 7 | # 8 | # For technical reasons, this file is *NOT* reloaded automatically when you use 9 | # 'bundle exec jekyll serve'. If you change this file, please restart the server process. 10 | # 11 | # If you need help with YAML syntax, here are some quick references for you: 12 | # https://learn-the-web.algonquindesign.ca/topics/markdown-yaml-cheat-sheet/#yaml 13 | # https://learnxinyminutes.com/docs/yaml/ 14 | # 15 | # Site settings 16 | # These are used to personalize your new site. If you look in the HTML files, 17 | # you will see them accessed via {{ site.title }}, {{ site.email }}, and so on. 18 | # You can create any custom variable you would like, and they will be accessible 19 | # in the templates via {{ site.myvariable }}. 20 | 21 | title: Rimmel.js 22 | email: your-email@example.com 23 | description: A Streams-Oriented UI library for the Rx.Observable Universe 24 | baseUrl: "/" # the subpath of your site, e.g. /blog 25 | url: "https://rimmel.js.org" # the base hostname & protocol for your site, e.g. http://example.com 26 | #twitter_username: jekyllrb 27 | #github_username: jekyll 28 | 29 | # Build settings 30 | theme: jekyll-theme-leap-day 31 | plugins: 32 | # - jekyll-feed 33 | 34 | include: 35 | - handbook 36 | 37 | # Exclude from processing. 38 | # The following items will not be processed, by default. 39 | # Any item listed under the `exclude:` key here will be automatically added to 40 | # the internal "default list". 41 | # 42 | # Excluded items can be processed by explicitly listing the directories or 43 | # their entries' file path in the `include:` list. 44 | # 45 | # exclude: 46 | # - .sass-cache/ 47 | # - .jekyll-cache/ 48 | # - gemfiles/ 49 | # - Gemfile 50 | # - Gemfile.lock 51 | # - node_modules/ 52 | # - vendor/bundle/ 53 | # - vendor/cache/ 54 | # - vendor/gems/ 55 | # - vendor/ruby/ 56 | -------------------------------------------------------------------------------- /docs/_posts/_test.md.xxx: -------------------------------------------------------------------------------- 1 | --- 2 | layout: post 3 | title: "Welcome to Jekyll!" 4 | date: 2024-02-04 20:46:29 +0000 5 | categories: jekyll update 6 | --- 7 | You’ll find this post in your `_posts` directory. Go ahead and edit it and re-build the site to see your changes. You can rebuild the site in many different ways, but the most common way is to run `jekyll serve`, which launches a web server and auto-regenerates your site when a file is updated. 8 | 9 | Jekyll requires blog post files to be named according to the following format: 10 | 11 | `YEAR-MONTH-DAY-title.MARKUP` 12 | 13 | Where `YEAR` is a four-digit number, `MONTH` and `DAY` are both two-digit numbers, and `MARKUP` is the file extension representing the format used in the file. After that, include the necessary front matter. Take a look at the source for this post to get an idea about how it works. 14 | 15 | Jekyll also offers powerful support for code snippets: 16 | 17 | {% highlight ruby %} 18 | def print_hi(name) 19 | puts "Hi, #{name}" 20 | end 21 | print_hi('Tom') 22 | #=> prints 'Hi, Tom' to STDOUT. 23 | {% endhighlight %} 24 | 25 | Check out the [Jekyll docs][jekyll-docs] for more info on how to get the most out of Jekyll. File all bugs/feature requests at [Jekyll’s GitHub repo][jekyll-gh]. If you have questions, you can ask them on [Jekyll Talk][jekyll-talk]. 26 | 27 | [jekyll-docs]: https://jekyllrb.com/docs/home 28 | [jekyll-gh]: https://github.com/jekyll/jekyll 29 | [jekyll-talk]: https://talk.jekyllrb.com/ 30 | -------------------------------------------------------------------------------- /docs/assets/css/style.scss: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | @import "{{ site.theme }}"; 5 | 6 | body { font-size: 18px; line-height: 26px; } 7 | 8 | h1, h2, h3 { 9 | margin-top: 3rem; 10 | } 11 | 12 | header { background: #ac4142; } 13 | 14 | @media print, screen and (max-width: 720px) { 15 | header p { 16 | display: block; 17 | } 18 | } 19 | 20 | #banner { 21 | border-radius: 0px 2px .6rem 0px; 22 | } 23 | 24 | section { 25 | padding: 1rem; 26 | margin-top: 170px; 27 | } 28 | 29 | section img { 30 | display: block; 31 | margin-left: auto; 32 | margin-right: auto; 33 | max-width: 100%; 34 | } 35 | 36 | 37 | @media print, screen and (max-width: 720px) { 38 | #banner { 39 | top: 115px; 40 | } 41 | 42 | #banner .fork { 43 | float: none; 44 | display: inline-block; 45 | position: static; 46 | margin-left: 1rem; 47 | } 48 | } 49 | 50 | @media print, screen and (max-width: 480px) { 51 | #banner { 52 | position: static; 53 | display: block; 54 | } 55 | 56 | section { 57 | position: static; 58 | margin-top: 0px; 59 | } 60 | } 61 | 62 | #banner #logo { 63 | background: url(/assets/rimmel.png); 64 | background-size: cover; 65 | filter: saturate(0) brightness(2); 66 | } 67 | 68 | code { font-family: monospace; font-size: 12px; } 69 | 70 | code>* { 71 | margin: 0; 72 | padding: 0; 73 | line-height: normal; 74 | } 75 | 76 | ul{ list-style-type: disclosure-closed; list-style-image: none; } 77 | ::marker{ color: #ac4142; } 78 | 79 | footer { 80 | position: static; 81 | width: auto; 82 | margin-left: 0; 83 | } 84 | 85 | h1, h2, h3 { 86 | margin-block-start: 4rem; 87 | color: #aa0033ff; 88 | } 89 | 90 | .playground-link img { 91 | display: none; 92 | } 93 | 94 | .playground-link { 95 | margin-block: 3rem; 96 | margin-inline: 0 1rem; 97 | font-size: 125%; 98 | white-space: nowrap; 99 | } 100 | 101 | .playground-link::before { 102 | display: inline-block; 103 | margin-inline: 0 2rem; 104 | padding: .6rem 1rem; 105 | background: #aa0033ff; 106 | color: gold; 107 | border-top-right-radius: .4rem; 108 | border-bottom-right-radius: .4rem; 109 | border-spacing: 4px; 110 | content: "Try it ⮞" 111 | } 112 | 113 | -------------------------------------------------------------------------------- /docs/assets/event-adapters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/assets/event-adapters.png -------------------------------------------------------------------------------- /docs/assets/github-hero-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/assets/github-hero-2.png -------------------------------------------------------------------------------- /docs/assets/how-rimmel-testing-works.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/assets/how-rimmel-testing-works.png -------------------------------------------------------------------------------- /docs/assets/how-rimmel-works-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/assets/how-rimmel-works-3.png -------------------------------------------------------------------------------- /docs/assets/how-rimmel-works-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/assets/how-rimmel-works-4.png -------------------------------------------------------------------------------- /docs/assets/how-rimmel-works-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/assets/how-rimmel-works-5.png -------------------------------------------------------------------------------- /docs/assets/how-rimmel-works-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/assets/how-rimmel-works-6.png -------------------------------------------------------------------------------- /docs/assets/how-rimmel-works-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/assets/how-rimmel-works-7.png -------------------------------------------------------------------------------- /docs/assets/how-rimmel-works-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/assets/how-rimmel-works-9.png -------------------------------------------------------------------------------- /docs/assets/how-rimmel-works.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/assets/how-rimmel-works.png -------------------------------------------------------------------------------- /docs/assets/isolating-side-effects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/assets/isolating-side-effects.png -------------------------------------------------------------------------------- /docs/assets/observable-types.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/assets/observable-types.png -------------------------------------------------------------------------------- /docs/assets/rimmel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/assets/rimmel.png -------------------------------------------------------------------------------- /docs/assets/try-it-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/assets/try-it-button.png -------------------------------------------------------------------------------- /docs/docs/assets: -------------------------------------------------------------------------------- 1 | ../assets -------------------------------------------------------------------------------- /docs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/docs/favicon.png -------------------------------------------------------------------------------- /examples/kitchen-sink/flower.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReactiveHTML/rimmel/511feb4c61f552edd8deac083dbed499ca03c08e/examples/kitchen-sink/flower.webm -------------------------------------------------------------------------------- /examples/kitchen-sink/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const common = { 2 | verbose: true, 3 | testPathIgnorePatterns: ['/node_modules/'], 4 | coveragePathIgnorePatterns: ['node_modules'], 5 | transform: { '^.+\\.(j|t)s(x)?$': 'esbuild-jest' }, 6 | moduleFileExtensions: ['js'], 7 | }; 8 | 9 | module.exports = { 10 | projects: [ 11 | { 12 | ...common, 13 | displayName: 'dom', 14 | testEnvironment: 'jsdom', 15 | testRegex: '/src/.*\\.web\\.test\\.js$', 16 | }, 17 | { 18 | ...common, 19 | displayName: 'node', 20 | testEnvironment: 'node', 21 | testRegex: '/src/.*\\.node\\.test\\.js$', 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rimmel", 3 | "version": "1.4.4", 4 | "description": "A Streams-Oriented UI library for the Rx.Observable Universe", 5 | "type": "module", 6 | "_main": "dist/cjs/index.cjs", 7 | "module": "dist/esm/index.js", 8 | "types": "dist/esm/types/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "import": "./dist/esm/index.js", 12 | "types": "./dist/esm/types/index.d.ts" 13 | }, 14 | "./ssr": { 15 | "import": "./dist/ssr/index.mjs", 16 | "require": "./dist/ssr/index.cjs" 17 | } 18 | }, 19 | "files": [ 20 | "dist/" 21 | ], 22 | "sideEffects": [ 23 | "./src/lifecycle/data-binding.ts" 24 | ], 25 | "scripts": { 26 | "build": "rimraf dist && rollup --config rollup.config.js", 27 | "dev": "rimraf dist && rollup -w --config rollup.config.js", 28 | "handbook:dev": "npx typedoc --watch", 29 | "handbook": "npx typedoc", 30 | "kitchen-sink": "cd examples/kitchen-sink && vite", 31 | "website": "cd docs && bundle exec jekyll build", 32 | "website:local": "cd docs && bundle exec jekyll serve", 33 | "test": "bun test", 34 | "test:types": "tsd --files src/**/*.test-d.ts" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/reactivehtml/rimmel.git" 39 | }, 40 | "keywords": [ 41 | "rimmel", 42 | "rimmel.js", 43 | "rimmeljs", 44 | "rxjs", 45 | "rx", 46 | "html", 47 | "observables", 48 | "observable", 49 | "reactive", 50 | "reactive html", 51 | "reactive dom", 52 | "rml", 53 | "reactive-markup", 54 | "stream", 55 | "streams-oriented", 56 | "streams-oriented programming", 57 | "fp", 58 | "functional programming", 59 | "functional/reactive", 60 | "functional/reactive programming" 61 | ], 62 | "author": "Dario Mannu", 63 | "license": "MIT", 64 | "bugs": { 65 | "url": "https://github.com/reactivehtml/rimmel/issues" 66 | }, 67 | "homepage": "https://rimmel.js.org", 68 | "devDependencies": { 69 | "@rollup/plugin-commonjs": "^25.0.7", 70 | "@rollup/plugin-json": "^6.1.0", 71 | "@rollup/plugin-node-resolve": "^15.2.3", 72 | "@rollup/plugin-typescript": "^11.1.6", 73 | "@types/jest": "^29.5.11", 74 | "@types/node": "^22.10.2", 75 | "esbuild": "^0.19.11", 76 | "esbuild-jest": "^0.5.0", 77 | "jest": "^27.2.0", 78 | "rimraf": "^3.0.2", 79 | "rollup": "^4.9.5", 80 | "rollup-plugin-visualizer": "^5.12.0", 81 | "rxjs": "^7.8.1", 82 | "ts-node": "^10.9.2", 83 | "tsd": "^0.31.2", 84 | "tslib": "^2.8.0", 85 | "typedoc": "^0.27.5", 86 | "typescript": "^5.7.2", 87 | "vite": "^5.4.9" 88 | }, 89 | "peerDependencies": { 90 | "rxjs": ">=5.5.0 || >=6.0.0 || >=7.0.0 || =8.0.0-alpha.14" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /rollup.config.dts.js: -------------------------------------------------------------------------------- 1 | import dts from 'rollup-plugin-dts'; 2 | 3 | export default { 4 | input: './dist/esm/types/index.d.ts', // The main entry for types 5 | output: { 6 | file: './dist/esm/rimmel.d.ts', 7 | format: 'es' 8 | }, 9 | plugins: [dts()] 10 | }; 11 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import json from '@rollup/plugin-json'; 3 | import { join } from 'path'; 4 | import typescript from '@rollup/plugin-typescript'; 5 | import { visualizer } from 'rollup-plugin-visualizer'; 6 | 7 | const getTSConfig = async (path) => { 8 | const tsConfig = (await import('./tsconfig.json', { assert: { type: 'json' } })).default; 9 | 10 | tsConfig.compilerOptions.outDir = path; 11 | tsConfig.compilerOptions.declarationDir = join(path, 'types'); 12 | return tsConfig.compilerOptions; 13 | }; 14 | 15 | export default [ 16 | { // Global JS 17 | external: ['rxjs'], 18 | input: './src/index.ts', 19 | treeshake: { 20 | propertyReadSideEffects: false, // Optimise property access side effects 21 | }, 22 | plugins: [ 23 | nodeResolve({ preferBuiltins: true }), 24 | // json(), 25 | typescript({ 26 | ...await getTSConfig('dist/globaljs'), 27 | sourceMap: true, 28 | outDir: 'dist/globaljs', 29 | declaration: false, 30 | declarationDir: undefined, // Explicitly unset 31 | declarationMap: false, // Explicitly disable 32 | }), 33 | visualizer({ filename: 'bundle-stats-globaljs.html' }), 34 | ], 35 | output: [{ 36 | exports: 'named', 37 | externalLiveBindings: false, 38 | dir: './dist/globaljs', 39 | entryFileNames: '[name].mjs', 40 | freeze: true, 41 | generatedCode: 'es2015', 42 | format: 'iife', 43 | name: 'rml', 44 | globals: { 45 | 'rxjs': 'rxjs', 46 | }, 47 | sourcemap: true, 48 | }], 49 | }, 50 | 51 | { // ESM 52 | external: ['rxjs'], 53 | input: './src/index.ts', 54 | treeshake: { 55 | propertyReadSideEffects: false, // Optimise property access side effects 56 | }, 57 | plugins: [ 58 | nodeResolve({ preferBuiltins: true }), 59 | json(), 60 | typescript({ 61 | ...await getTSConfig('dist/esm'), 62 | }), 63 | visualizer({ filename: 'bundle-stats-esm.html' }), 64 | ], 65 | output: [ 66 | { 67 | exports: 'named', 68 | externalLiveBindings: false, 69 | freeze: false, 70 | sourcemap: true, 71 | entryFileNames: '[name].js', 72 | format: 'es', 73 | dir: './dist/esm', 74 | preserveModules: true, 75 | } 76 | ], 77 | }, 78 | 79 | { // SSR 80 | external: ['rxjs'], 81 | input: './src/ssr/index.ts', 82 | treeshake: { 83 | moduleSideEffects: 'no-external', // Only shake internal code 84 | propertyReadSideEffects: false, // Optimise property access side effects 85 | }, 86 | plugins: [ 87 | nodeResolve({ preferBuiltins: true }), 88 | json(), 89 | typescript({ 90 | ...await getTSConfig('dist/ssr'), 91 | sourceMap: true, 92 | outDir: 'dist/ssr', 93 | declaration: true, 94 | }), 95 | visualizer({ filename: 'bundle-stats-ssr.html' }), 96 | ], 97 | output: [ 98 | { 99 | exports: 'named', 100 | externalLiveBindings: false, 101 | dir: './dist/ssr', 102 | entryFileNames: '[name].mjs', 103 | format: 'es', 104 | freeze: false, 105 | sourcemap: true, 106 | }, 107 | { 108 | exports: 'named', 109 | externalLiveBindings: false, 110 | dir: './dist/ssr', 111 | entryFileNames: '[name].cjs', 112 | format: 'cjs', 113 | freeze: false, 114 | sourcemap: true, 115 | } 116 | ], 117 | }, 118 | ]; 119 | 120 | -------------------------------------------------------------------------------- /server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | http-server dist/esm -c-1 -t300 -p8000 --cors 4 | # http-server . -c-1 -t300 -p8000 --cors 5 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import type { RenderingScheduler } from './types/schedulers'; 2 | 3 | declare global { 4 | interface Window { 5 | RMLREF: string; 6 | } 7 | } 8 | self.RMLREF=''; 9 | 10 | export const REF_TAG: string = 'RMLREF+'; 11 | export const REF_REGEXP: RegExp = /^RMLREF+\d+$/; 12 | 13 | // custom attribute and corresponding selector to find just-mounted elements 14 | // that need any data binding 15 | export const RESOLVE_ATTRIBUTE: string = 'resolve'; // keep lowercase for SVG 16 | export const RESOLVE_SELECTOR: string = `[${RESOLVE_ATTRIBUTE}]`; 17 | 18 | // An equivalent of the "debugger;" JavaScript expression, for templates 19 | export const RML_DEBUG: string = 'rml:debugger'; 20 | 21 | // Special, non-printable Unicode characters to wrap interactive text nodes 22 | // letting Rimmel know they'll need to be rendered as Text Nodes in the DOM, for updates 23 | export const INTERACTIVE_NODE_START = '\u200B'; 24 | export const INTERACTIVE_NODE_END = '\u200C'; // FIXME: can't use this 25 | 26 | export const SOURCE_TAG: string = 'source'; 27 | export const SINK_TAG: string = 'sink'; 28 | 29 | // Use the new native Web Platform Observables instead of addEventListener when available 30 | export var USE_DOM_OBSERVABLES: boolean = false; 31 | export const set_USE_DOM_OBSERVABLES = ((x: boolean) => USE_DOM_OBSERVABLES = x); 32 | 33 | export const SymbolObservature = Symbol.for('observature'); 34 | 35 | // export var renderingScheduler = '../schedulers/ema-animation-frame'; 36 | export var renderingScheduler: RenderingScheduler | null = null; 37 | export const setRenderingScheduler = (scheduler: RenderingScheduler) => renderingScheduler = scheduler; 38 | 39 | // export const configure = () => { 40 | // return rml 41 | // } 42 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | // Show stack traces for Sources and Sinks 2 | export const tracing: boolean = true; 3 | 4 | -------------------------------------------------------------------------------- /src/definitions/boolean-attributes.ts: -------------------------------------------------------------------------------- 1 | // List of HTML boolean attributes 2 | // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML 3 | // These enable a certain functionality by their mere presence in a tag. 4 | // E.G.: is still disabled, which is unintuitive. 5 | // should really set or unset the disabled state depending on the stream's last emitted value! 6 | // If you don't like this behaviour, we have a "rml" prefixed set of such attributes, that actually behave like booleans 7 | 8 | // TODO: review, see if we can convert to a type... don't want all these in the bundles 9 | export const BOOLEAN_ATTRIBUTES = new Set([ 10 | 'async', 11 | 'autofocus', 12 | 'autoplay', 13 | 'checked', 14 | 'controls', 15 | 'default', 16 | 'defer', 17 | 'disabled', 18 | 'formnovalidate', 19 | 'hidden', 20 | 'ismap', 21 | 'loop', 22 | 'multiple', 23 | 'muted', 24 | 'nomodule', 25 | 'novalidate', 26 | 'open', 27 | 'readonly', 28 | 'required', 29 | 'reversed', 30 | 'selected' 31 | ] as const); 32 | 33 | export type BooleanAttribute = (typeof BOOLEAN_ATTRIBUTES)['values'] extends () => Iterator ? T : never; 34 | 35 | -------------------------------------------------------------------------------- /src/definitions/enumerated-attributes.ts: -------------------------------------------------------------------------------- 1 | // List of HTML enumerated attributes 2 | // https://developer.mozilla.org/en-US/docs/Glossary/Enumerated#aria_enumerated_attributes 3 | // TODO: convert to a type... don't need all these in the bundles 4 | export const ENUMERATED_ATTRIBUTES = new Set([ 5 | 'autocomplete', 6 | 'capture', 7 | 'charset', 8 | 'contenteditable', 9 | 'crossorigin', 10 | 'decoding', 11 | 'dir', 12 | 'draggable', 13 | 'enctype', 14 | 'formmethod', 15 | 'formenctype', 16 | 'formtarget', 17 | 'inputmode', 18 | 'loading', 19 | 'method', 20 | 'preload', 21 | 'referrerpolicy', 22 | 'rel', 23 | 'scope', 24 | 'shape', 25 | 'spellcheck', 26 | 'translate', 27 | 'type', 28 | 'wrap', 29 | ]); 30 | 31 | -------------------------------------------------------------------------------- /src/definitions/non-bubbling-events.ts: -------------------------------------------------------------------------------- 1 | import type {RMLEventName} from '../types/dom'; 2 | 3 | // TODO: if we keep using this, maybe convert it to a type 4 | export const NON_BUBBLING_DOM_EVENTS: Set = new Set([ 5 | 'abort', 6 | 'canplay', 7 | 'canplaythrough', 8 | 'durationchange', 9 | 'emptied', 10 | 'ended', 11 | 'error', 12 | 'load', 13 | 'loadeddata', 14 | 'loadedmetadata', 15 | 'pause', 16 | 'play', 17 | 'playing', 18 | 'ratechange', 19 | 'seeked', 20 | 'seeking', 21 | 'stalled', 22 | 'suspend', 23 | 'timeupdate', 24 | 'volumechange', 25 | 'waiting', 26 | 'rml:mount', 27 | ]); 28 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Rimmel_Mount } from './lifecycle/data-binding'; 2 | // import { rml } from './parser/parser'; 3 | 4 | export const init = (root = document.documentElement) => { 5 | const mo = new MutationObserver(Rimmel_Mount); 6 | mo.observe(root, { attributes: false, childList: true, subtree: true }); 7 | }; 8 | 9 | // import { Rimmel_Bind_Subtree } from './lifecycle/data-binding'; 10 | // export const activate = Rimmel_Bind_Subtree; 11 | 12 | /* 13 | export const setRoot = (e: Element): RML => { 14 | const root = e; 15 | init(root); 16 | return rml; 17 | } 18 | */ 19 | 20 | init(); 21 | 22 | // Types 23 | export * from './types/attribute'; 24 | export * from './types/coords'; 25 | export * from './types/dom'; 26 | export * from './types/event-listener'; 27 | export * from './types/futures'; 28 | export * from './types/internal'; 29 | export * from './types/rml'; 30 | export * from './types/sink'; 31 | export * from './types/source'; 32 | export * from './types/style'; 33 | export * from './definitions/boolean-attributes'; 34 | export * from './utils/input-pipe'; 35 | export * from './sources/object-source'; 36 | 37 | // Event Mapping Functions 38 | export { feed, feedIn, inputPipe, pipeIn } from './utils/input-pipe'; 39 | export { curry } from './utils/curry'; 40 | 41 | // Event Source Modifiers 42 | export { Active } from './sources/modifiers/active'; 43 | export { Passive } from './sources/modifiers/passive'; 44 | 45 | // Event Sources 46 | export { All, qsa } from './sources/all-source'; 47 | export { AutoForm } from './sources/autoform-source'; 48 | export { CheckedState } from './sources/checked-source'; 49 | export { Cut, cut } from './sources/cut-source'; 50 | export { Dataset, DatasetObject, datasetObject } from './sources/dataset-source'; 51 | export { Numberset } from './sources/numberset-source'; 52 | export { EventData, eventData } from './sources/event-data'; 53 | export { EventTarget } from './sources/event-target'; 54 | export { Form, form, AsFormData, asFormData } from './sources/form-data-source'; 55 | export { Key, key } from './sources/keyboard-source'; 56 | export { Update } from './sources/object-source'; 57 | export { ClientXY } from './sources/client-xy-source'; 58 | export { OffsetXY } from './sources/offset-xy-source'; 59 | export { LastTouchXY } from './sources/last-touch-xy-source'; 60 | export { Swap } from './sources/swap-source'; 61 | export { AsLatestFrom } from './sources/as-latest-from'; 62 | export { Value, ValueAsDate, ValueAsNumber, value, valueAsString, valueAsDate, valueAsNumber } from './sources/value-source'; 63 | 64 | // Data Sinks 65 | export { AnyContentSink } from "./sinks/content-sink"; 66 | export { AttributeObjectSink } from "./sinks/attribute-sink"; 67 | // Data Sinks 68 | export { AppendHTML } from './sinks/append-html-sink'; 69 | export { Blur } from './sinks/blur-sink'; 70 | export { Checked } from './sinks/checked-sink'; 71 | export { ClassName, ToggleClass } from './sinks/class-sink'; 72 | export { Closed } from './sinks/closed-sink'; 73 | export { Disabled } from './sinks/disabled-sink'; 74 | export { Catch } from './sinks/error-sink'; 75 | export { Focus } from './sinks/focus-sink'; 76 | export { Hidden } from './sinks/hidden-sink'; 77 | export { InnerHTML } from './sinks/inner-html-sink'; 78 | export { InnerText } from './sinks/inner-text-sink'; 79 | export { Mixin } from './sinks/mixin-sink'; 80 | export { JSONDump } from './sinks/json-dump-sink'; 81 | export { PrependHTML } from './sinks/prepend-html-sink'; 82 | //export { Readonly } from './sinks/readonly-sink'; 83 | export { Removed } from './sinks/removed-sink'; 84 | export { Sanitize } from './sinks/sanitize-html-sink'; 85 | export { Suspend, Suspender } from './sinks/suspense-sink'; 86 | export { TextContent } from './sinks/text-content-sink'; 87 | 88 | // Plumbing 89 | export { asap } from './lib/drain'; 90 | 91 | // Experimental Web Component support 92 | export { RegisterElement } from './custom-element'; 93 | 94 | // Utilities (Will take them out to the framework) 95 | export type { Component } from './types/constructs'; 96 | export type { RimmelComponent, RMLTemplateExpressions } from './types/internal'; 97 | 98 | export { source, sink } from './utils/input-pipe'; 99 | 100 | // Other Low-Level Utilities 101 | export { Rimmel_Bind_Subtree, Rimmel_Mount } from './lifecycle/data-binding'; 102 | export { RESOLVE_SELECTOR, RML_DEBUG, SINK_TAG } from './constants'; 103 | export { set_USE_DOM_OBSERVABLES } from './constants'; 104 | 105 | // Main entries 106 | export { rml } from './parser/parser'; 107 | export { rml as html } from './parser/parser'; // Shall we? 108 | 109 | // Experimental stuff 110 | // export { rml } from 'rml/scandal'; 111 | export { Observature } from './lib/observature'; 112 | 113 | -------------------------------------------------------------------------------- /src/internal-state.ts: -------------------------------------------------------------------------------- 1 | import type { BindingConfiguration, SourceBindingConfiguration } from "./types/internal"; 2 | import type { RMLEventName } from "./types/dom"; 3 | import type { Subscription } from "./types/futures"; 4 | 5 | import { REF_TAG } from './constants'; 6 | 7 | export const waitingElementHanlders = >new Map(); 8 | // TODO: Test and verify with WeakRef/FinalizationRegistry 9 | export const delegatedEventHandlers: WeakMap[]> = new WeakMap(); 10 | export const subscriptions: Map = new Map(); 11 | // #REF49993849837451 12 | // export const listeners: WeakMap = new WeakMap(); 13 | export const delegatedEvents = new Set(); 14 | 15 | // FIXME: add a unique prefix to prevent collisions with different dupes of the library running in the same context/app 16 | export const state = { 17 | refCount: 0, 18 | } 19 | 20 | export const newRef = () => REF_TAG +state.refCount++; 21 | 22 | -------------------------------------------------------------------------------- /src/lib/addListener.ts: -------------------------------------------------------------------------------- 1 | import { RMLEventName } from "../types"; 2 | import { IObservature, isObservature } from "./observature"; 3 | import { USE_DOM_OBSERVABLES } from "../constants"; 4 | import { MonkeyPatchedObservable } from "../types/monkey-patched-observable"; 5 | import { RMLEventListener } from "../types/event-listener"; 6 | import { toListener } from "../utils/to-listener"; 7 | 8 | const isEventListenerObject = (l: any): l is EventListenerObject => !!l?.handleEvent; 9 | 10 | export const addListener = (node: EventTarget, eventName: RMLEventName, listener: RMLEventListener, options?: AddEventListenerOptions | boolean) => { 11 | // 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 12 | if (USE_DOM_OBSERVABLES && node.when) { 13 | // Explicitly excluding the isEventListenerObject as Domenic doesn't want .when() to support it 14 | if (!isEventListenerObject(listener)) { 15 | const source = node.when(eventName, options); 16 | if (isObservature(listener)) { 17 | (>listener).addSource(source as MonkeyPatchedObservable); 18 | } else { 19 | // TODO: Add AbortController 20 | source.subscribe(listener); 21 | } 22 | } 23 | } else { 24 | node.addEventListener(eventName, toListener(listener), options); 25 | // #REF49993849837451 26 | // const listenerRef = [eventName, sourceBindingConfiguration.listener, sourceBindingConfiguration.options]; 27 | // node.addEventListener(...listenerRef); 28 | // listeners.get(node)?.push?.(listenerRef) ?? listeners.set(node, [listenerRef]); 29 | } 30 | 31 | if (/^(?:rml:)?mount/.test(eventName)) { 32 | // Will this need to bubble up? (probably no) 33 | setTimeout(() => node.dispatchEvent(new Event(eventName))); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/lib/drain.ts: -------------------------------------------------------------------------------- 1 | import type { SinkFunction } from "../types/sink"; 2 | import type { EventListenerObject, EventListenerOrEventListenerObject } from "../types/dom"; 3 | import type { MaybeFuture, Observable, Observer, ObserverErrorFunction, ObserverFunction } from "../types/futures"; 4 | import type { RenderingScheduler } from "../types/schedulers"; 5 | 6 | import { isObservable, isPromise } from "../types/futures"; 7 | import { subscriptions } from "../internal-state"; 8 | // import renderingScheduler from '../schedulers/ema-animation-frame'; 9 | import { toListener } from "../utils/to-listener"; 10 | 11 | /** 12 | * Return the "callable" part of an entity: 13 | * - the next method of an Observer 14 | * - the handleEvent method of an EventListenerObject 15 | * - the function itself, if it's a function 16 | */ 17 | export const callable = (x: (Observer | EventListenerObject | ObserverFunction)) => 18 | (x as Observer).next ? (x as Observer).next.bind(x) : 19 | (x as EventListenerObject).handleEvent ? (x as EventListenerObject).handleEvent.bind(x) : 20 | (x as (t: T)=>any) 21 | ; 22 | 23 | // FIXME: remove, use subscribe below instead 24 | export const asap = (fn: ObserverFunction | Observer, arg: MaybeFuture) => { 25 | (>arg)?.subscribe?.(fn) ?? 26 | (>arg)?.then?.((fn as Observer).next?.bind(fn) ?? fn) ?? 27 | (fn as ObserverFunction)(arg as T); 28 | }; 29 | 30 | /** 31 | * Connect an event source to a sink through any compatible interface on any optionally specified scheduler 32 | * @param node The node on which the binding is set 33 | * @param source A Promise, Observable or EventEmitter 34 | * @param next A "next" or "then" handler on the sink side 35 | * @param error? An error handler on the sink side 36 | * @param complete? a finalisation function 37 | */ 38 | export const subscribe = 39 | 40 | (node: Node, source: MaybeFuture, next: EventListenerOrEventListenerObject, error?: ObserverErrorFunction, complete?: () => void, scheduler?: RenderingScheduler) => { 41 | // TODO: make this a plugin, in case people don't use handleEvent... 42 | const flattenedNext = toListener(next); 43 | const task = scheduler?.(node, flattenedNext) ?? flattenedNext; 44 | 45 | if (isObservable(source)) { 46 | // TODO: should we handle promise cancellations (cancellable promises?) too? 47 | const subscription = source.subscribe({ 48 | next: task, 49 | error, 50 | complete, 51 | }); 52 | 53 | subscriptions.get(node)?.push(subscription) ?? subscriptions.set(node, [subscription]); 54 | 55 | return subscription; 56 | } else if (isPromise(source)) { 57 | source.then(task, error).finally(complete); 58 | } else { 59 | // TODO: should we handle function cancellations (removeEventListener) too? 60 | (task)(source); 61 | } 62 | } 63 | ; 64 | -------------------------------------------------------------------------------- /src/lib/observature.ts: -------------------------------------------------------------------------------- 1 | import type { Observable as Observable1, Observer } from '../types/futures'; 2 | import type { MonkeyPatchedObservable as Observable2 } from '../types/monkey-patched-observable'; 3 | import { SymbolObservature } from '../constants'; 4 | 5 | type Observable = Observable1 | Observable2; 6 | type OperatorPreload = [string, unknown[]]; 7 | 8 | export interface IObservature { 9 | value: O; 10 | Observature: true; 11 | [SymbolObservature]: true; 12 | addSource: (source: Observable) => void; 13 | next: (value: O) => void; 14 | error: (error: unknown) => void; 15 | complete: () => void; 16 | subscribe: (observer: Observer) => void; 17 | } 18 | 19 | export const CreateObservature = (initial?: O) => { 20 | let sources: Observable[] = []; 21 | let subscribers: Observer[] = []; 22 | 23 | const operators = []; 24 | const output = new Observable((observer: Observer) => { 25 | subscribers.push(observer); 26 | return { 27 | unsubscribe: () => { 28 | subscribers = subscribers.filter(sub => sub !== observer); 29 | } 30 | }; 31 | }); 32 | 33 | const applyPipeline = (source: Observable) => { 34 | const pipeline = operators.reduce((obs, [prop, args]: OperatorPreload) => obs[prop](...args), source); 35 | return pipeline.subscribe({ 36 | next: (val: O) => subscribers.forEach(sub => sub.next?.(val) ?? sub(val)), 37 | error: (error: unknown) => subscribers.forEach(sub => sub.error?.(error) ?? sub(error)), 38 | complete: () => subscribers.forEach(sub => sub.complete?.()) 39 | }); 40 | }; 41 | 42 | return new Proxy(output, { 43 | get(target, prop) { 44 | switch(prop) { 45 | case 'value': 46 | return initial; 47 | 48 | case Symbol.for('observable'): 49 | case '@@observable': 50 | return function() { return this }; 51 | 52 | case '@@Observature': 53 | case 'Observature': 54 | case SymbolObservature: 55 | return true; 56 | 57 | case 'addSource': 58 | return (_source: Observable) => { 59 | sources.push(_source); 60 | return target; 61 | } 62 | 63 | case 'type': 64 | return undefined; 65 | 66 | case 'next': 67 | return (value: O) => applyPipeline(Observable.from([value])); 68 | 69 | case 'error': 70 | return (error: unknown) => subscribers.forEach(sub => sub.error?.(error) ?? sub(error)); 71 | 72 | case 'complete': 73 | return () => subscribers.forEach(sub => sub.complete?.()); 74 | 75 | case 'subscribe': 76 | return (_observer: Observer) => { 77 | subscribers.push(_observer); 78 | const starter = Observable.merge(...([]>[]).concat(sources, initial ? Observable.from([].concat(initial ?? [])) : []>[])); 79 | const subscription = applyPipeline(starter); 80 | if(initial !== undefined) { 81 | subscribers.forEach(sub => sub.next?.(initial)); 82 | } 83 | return subscription; 84 | } 85 | 86 | default: 87 | if(Observable.prototype.hasOwnProperty(prop)) { 88 | return function(...args: (keyof Observable)[]) { 89 | // FIXME: this should return a new Observature 90 | // (or a separate pipeline rather than modifying the original?) 91 | // we still want to keep the same sources 92 | operators.push([prop, args]); 93 | return this; 94 | } 95 | } 96 | return (target as any)[prop]; 97 | } 98 | } 99 | }) as unknown as IObservature; 100 | }; 101 | 102 | export class Observature{ 103 | constructor(initial: O) { 104 | return CreateObservature(initial); 105 | } 106 | }; 107 | 108 | export const isObservature = (x: any): x is IObservature => 109 | x?.Observature || x[SymbolObservature] 110 | ; -------------------------------------------------------------------------------- /src/parser/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { waitingElementHanlders } from '../internal-state'; 2 | import { RMLEventName } from '../types/dom'; 3 | import { rml } from './parser'; 4 | 5 | describe('Parser', () => { 6 | // FIXME: move to a beforeEeach() call. Doesn't seem to work in Bun. 7 | global.document = globalThis.document || {}; 8 | global.document = globalThis.document; 9 | global.document.addEventListener = (eventName: RMLEventName, handler: EventListenerOrEventListenerObject) => {}; 10 | waitingElementHanlders.clear(); 11 | 12 | describe('Sources', () => { 13 | describe('Event Handlers', () => { 14 | const handlerFn = () => {}; 15 | const template = rml`
Hello
`; 16 | 17 | expect(template).toEqual('
Hello
'); 18 | expect(waitingElementHanlders.get('RMLREF+0')).toEqual([{ 19 | eventName: 'click', 20 | listener: handlerFn, 21 | type: 'source', 22 | }]); 23 | 24 | }); 25 | 26 | }); 27 | 28 | describe('Sinks', () => { 29 | }); 30 | 31 | describe('Plain Objects', () => { 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/parser/sink-map.ts: -------------------------------------------------------------------------------- 1 | import type { RMLTemplateExpressions, SinkBindingConfiguration } from "../types/internal"; 2 | import type { Sink } from "../types/sink"; 3 | 4 | import { AppendHTMLSink } from "../sinks/append-html-sink"; 5 | import { BlurSink } from "../sinks/blur-sink"; 6 | import { CheckedSink } from "../sinks/checked-sink"; 7 | import { ClassName, ClassObjectSink, ToggleClass } from "../sinks/class-sink"; 8 | import { ClosedSink } from "../sinks/closed-sink"; 9 | import { DatasetSink, DatasetObjectSink } from "../sinks/dataset-sink"; 10 | import { DisabledSink } from "../sinks/disabled-sink"; 11 | import { FocusSink } from "../sinks/focus-sink"; 12 | import { HiddenSink } from "../sinks/hidden-sink"; 13 | import { InnerHTMLSink } from "../sinks/inner-html-sink"; 14 | import { InnerTextSink } from "../sinks/inner-text-sink"; 15 | import { ReadonlySink } from "../sinks/readonly-sink"; 16 | import { RemovedSink } from "../sinks/removed-sink"; 17 | import { SubtreeSink } from "../sinks/subtree-sink"; 18 | import { StyleObjectSink } from "../sinks/style-sink"; 19 | import { TextContentSink } from "../sinks/text-content-sink"; 20 | import { ToggleAttributePreSink } from "../sinks/attribute-sink"; 21 | import { ValueSink } from "../sinks/value-sink"; 22 | 23 | export const sinkByAttributeName = new Map(]>>[ 24 | ['appendHTML', AppendHTMLSink], 25 | ['checked', CheckedSink], 26 | ['class', ClassObjectSink], 27 | //['contenteditable', ToggleAttributePreSink('contenteditable')], 28 | ['data-', DatasetSink], 29 | ['dataset', DatasetObjectSink], // Shall we include this, too? 30 | ['disabled', DisabledSink], 31 | ['hidden', HiddenSink], 32 | ['innerHTML', InnerHTMLSink], 33 | ['innerText', InnerTextSink], 34 | ['readonly', ReadonlySink], 35 | ['style', StyleObjectSink], 36 | // ['termination', terminationSink], // a sink that runs when an observable completes... will we ever need this? 37 | ['textContent', TextContentSink], 38 | ['value', ValueSink], 39 | ['rml:blur', BlurSink], 40 | // ['rml:checked', DisabledSink], // Can make this one act as an enumerated attribute that understands "false" and other values... 41 | ['rml:closed', ClosedSink], 42 | ['rml:dataset', DatasetObjectSink], 43 | // ['rml:disabled', DisabledSink], // Can make this one act as an enumerated attribute that understands "false" and other values... 44 | ['rml:focus', FocusSink], 45 | // ['rml:readonly', ReadonlySink], // Can make this one act as an enumerated attribute that understands "false" and other values... 46 | ['rml:removed', RemovedSink], 47 | ['rml:subtree', SubtreeSink], 48 | ['removed', RemovedSink], 49 | ['subtree', SubtreeSink], 50 | ]); 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/parser/ssr.ts: -------------------------------------------------------------------------------- 1 | import { isSinkBindingConfiguration, type RMLTemplateExpression } from '../types/internal'; 2 | import type { HTMLString } from '../types/dom'; 3 | import type { SinkBindingConfiguration } from '../types/internal'; 4 | 5 | import { rml as parser } from '../parser/parser'; 6 | import { waitingElementHanlders } from '../internal-state'; 7 | import { HydrationScript } from '../ssr/hydration'; 8 | 9 | import { Observable, endWith, filter, map, mergeAll, mergeWith, tap, from, of, isObservable } from 'rxjs'; 10 | 11 | let count = 0; 12 | export const rml = (strings: TemplateStringsArray, ...args: RMLTemplateExpression[]): Observable => { 13 | const hydrationCall = (data: string) => `\n`; 14 | const str: HTMLString = (parser(strings, ...args) + HydrationScript); 15 | const tasks = [...waitingElementHanlders.entries()] 16 | .flatMap(([key, jobs]) => 17 | jobs.map(job => { 18 | if(isSinkBindingConfiguration(job)) { 19 | const sb = job as SinkBindingConfiguration; 20 | const source$ = 21 | isObservable(sb.source) 22 | ? sb.source 23 | : from(sb.source as Promise) 24 | ; 25 | 26 | return source$.pipe( 27 | // tap(data=>console.log('EMIT:', data)), 28 | map(res => [key, { 29 | resolved: 'ssr', 30 | ...sb, 31 | // handler: sb.handler, 32 | value: res, 33 | sink: job.t, 34 | count: (count++), 35 | }]) 36 | ); 37 | } else { 38 | // TODO: Transpile sources to RPC calls? 39 | return null; 40 | } 41 | }) 42 | .filter(x=>x) 43 | ) 44 | ; 45 | 46 | const asyncStuff = () => from(tasks).pipe( 47 | filter(task => task !== null), 48 | mergeAll(), 49 | map(x => hydrationCall(JSON.stringify(x))) 50 | ); 51 | 52 | // TODO: just return a string here and pass asynquences separately 53 | return of(str).pipe( 54 | mergeWith(asyncStuff()), 55 | endWith('\n\n\n'), 56 | tap(() => waitingElementHanlders.clear()), 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /src/schedulers/adaptive-animation-frame.ts: -------------------------------------------------------------------------------- 1 | import type { RenderingScheduler, RenderingTask } from '../types/schedulers'; 2 | 3 | const queue = new Map(); 4 | 5 | let averageTaskTime = 0; 6 | 7 | const render = () => { 8 | const startTime = performance.now(); 9 | let remainingTime = 16; 10 | 11 | for (const [node, task] of queue) { 12 | const taskStart = performance.now(); 13 | task(); 14 | const taskDuration = performance.now() - taskStart; 15 | 16 | averageTaskTime = averageTaskTime 17 | ? (averageTaskTime + taskDuration) / 2 18 | : taskDuration; 19 | 20 | queue.delete(node); 21 | 22 | remainingTime = 16 - (performance.now() - startTime); 23 | 24 | if (remainingTime < averageTaskTime) { 25 | // trace queue.size; 26 | break; 27 | } 28 | } 29 | 30 | if (queue.size) { 31 | requestAnimationFrame(render); 32 | } 33 | }; 34 | 35 | export default ((node: Node, task: RenderingTask) => 36 | (...args: any[]) => { 37 | queue.size || requestAnimationFrame(render); 38 | queue.set(node, task.bind(null, ...args)); 39 | } 40 | ) as RenderingScheduler; 41 | -------------------------------------------------------------------------------- /src/schedulers/animation-frame-by-node.ts: -------------------------------------------------------------------------------- 1 | import type { RenderingScheduler, RenderingTask } from '../types/schedulers'; 2 | 3 | const queue = new Map(); 4 | 5 | const render = (_frameStart: DOMHighResTimeStamp) => { 6 | for (const [node, task] of queue) { 7 | task(); 8 | }; 9 | // window.qtt = queue.size; 10 | // window.att = (performance.now() - frameStart)/queue.size; 11 | queue.clear(); 12 | }; 13 | 14 | /** 15 | * A debounced batch-rendering scheduler that runs the latest rendering task for each given node at the next animation frame 16 | **/ 17 | export default ((node, task) => 18 | (...args: any[]) => { 19 | queue.size || requestAnimationFrame(render); 20 | queue.set(node, task.bind(null, ...args)); 21 | } 22 | ) as RenderingScheduler; 23 | 24 | -------------------------------------------------------------------------------- /src/schedulers/animation-frame.ts: -------------------------------------------------------------------------------- 1 | import type { RenderingScheduler, RenderingTask } from '../types/schedulers'; 2 | 3 | const queue = new Set(); 4 | 5 | const render = (frameStart: DOMHighResTimeStamp) => { 6 | for (const task of queue) { 7 | task(); 8 | }; 9 | // window.qtt = queue.size; 10 | // window.att = (performance.now() - frameStart)/queue.size; 11 | queue.clear(); 12 | }; 13 | 14 | /** 15 | * A basic batch-rendering scheduler that renders all tasks 16 | * at the next animation frame. 17 | * Suitable for simple to medium workloads where all rendering 18 | * tasks can be performed in one rendering frame 19 | **/ 20 | export default ((_node, task) => 21 | ((...args: any[]) => { 22 | queue.size || requestAnimationFrame(render); 23 | queue.add(task.bind(null, ...args)); 24 | }) 25 | ) as RenderingScheduler; 26 | 27 | -------------------------------------------------------------------------------- /src/schedulers/ema-animation-frame.ts: -------------------------------------------------------------------------------- 1 | import type { RenderingScheduler } from '../types/schedulers'; 2 | 3 | const queue = new Map(); 4 | let averageTaskTime = 0; 5 | const alpha = 0.8; // Adjust smoothing factor as needed 6 | const refreshRate = 1000 / 60; // Assume 60fps - FIXME: don't assume, calculate 7 | 8 | const render = (frameStart: DOMHighResTimeStamp) => { 9 | const frameEnd = frameStart + refreshRate; 10 | 11 | for (const [key, task] of queue) { 12 | const taskStart = performance.now(); 13 | task(); 14 | const taskDuration = performance.now() - taskStart; 15 | 16 | averageTaskTime = alpha *taskDuration +(1 -alpha) *averageTaskTime; 17 | 18 | queue.delete(key); 19 | 20 | if (performance.now() >= frameEnd) { 21 | // window.qtt = queue.size; 22 | // window.att = averageTaskTime; 23 | break; 24 | } 25 | } 26 | 27 | if (queue.size > 0) { 28 | requestAnimationFrame(render); 29 | } 30 | }; 31 | 32 | export default ((node, task) => 33 | (...args: any[]) => { 34 | queue.size || requestAnimationFrame(render); 35 | queue.set(node, task.bind(null, ...args)); // Update task in the queue 36 | } 37 | ) as RenderingScheduler; -------------------------------------------------------------------------------- /src/sinks/any-sink.ts: -------------------------------------------------------------------------------- 1 | import { sinkByAttributeName } from '../parser/sink-map'; 2 | import { MaybeFuture, Observable } from '../types/futures'; 3 | import { Sink, SinkFunction } from '../types/sink'; 4 | import { DOMAttributeSink } from './attribute-sink'; 5 | import { DatasetItemPreSink, DatasetObjectSink } from './dataset-sink'; 6 | import { asap } from '../lib/drain'; 7 | 8 | /** 9 | * A generic sink for anything that can be sinked to an Element 10 | **/ 11 | export const AnySink = (node: T, sinkType: string, v: MaybeFuture) => { 12 | // Fall back to 'attribute' unless it's any of the others 13 | // when someone emits an object called 'dataset' they mean 14 | // a dataset kvp for a DatasetObjectSink 15 | const sink: Sink = 16 | sinkType == 'dataset' ? DatasetObjectSink 17 | : sinkType.startsWith('data-') ? >DatasetItemPreSink(sinkType.substring(5)) 18 | : sinkByAttributeName.get(sinkType) 19 | ?? DOMAttributeSink; 20 | 21 | asap(sink(node, sinkType), v); // TODO: use drain() 22 | }; 23 | -------------------------------------------------------------------------------- /src/sinks/append-html-sink.test.ts: -------------------------------------------------------------------------------- 1 | import { MockElement } from '../test-support'; 2 | import { AppendHTMLSink } from './append-html-sink'; 3 | 4 | describe('AppendHTML Sink', () => { 5 | 6 | describe('Given any HTML', () => { 7 | 8 | it('appends to the innerHTML on sink', () => { 9 | const str1 = '
old text
'; 10 | const str2 = '
hello
'; 11 | 12 | const el = MockElement({ innerHTML: str1 }); 13 | const sink = AppendHTMLSink(el); 14 | 15 | sink(str2); 16 | expect(el.innerHTML).toEqual(str1 +str2); 17 | }); 18 | 19 | }); 20 | 21 | }); 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/sinks/append-html-sink.ts: -------------------------------------------------------------------------------- 1 | import type { ExplicitSink, Sink } from "../types/sink"; 2 | import type { RMLTemplateExpressions, SinkBindingConfiguration } from "../types/internal"; 3 | 4 | import { SINK_TAG } from "../constants"; 5 | 6 | export const APPEND_HTML_SINK_TAG = 'appendHTML'; 7 | 8 | export const AppendHTMLSink: Sink = 9 | (node: Element) => 10 | node.insertAdjacentHTML.bind(node, 'beforeend') 11 | ; 12 | 13 | /** 14 | * A specialised sink to append HTML to the end of an element 15 | * @param source A present or future HTML string 16 | * @returns RMLTemplateExpression An HTML-subtree or RML template expression 17 | * @example
${AppendHTML(stream)}
18 | */ 19 | export const AppendHTML: ExplicitSink<'content'> = (source: RMLTemplateExpressions.HTMLText, pos: InsertPosition = 'beforeend') => 20 | >({ 21 | type: SINK_TAG, 22 | t: APPEND_HTML_SINK_TAG, 23 | source, 24 | sink: AppendHTMLSink, 25 | params: pos, 26 | }) 27 | ; 28 | 29 | -------------------------------------------------------------------------------- /src/sinks/attribute-sink.test.ts: -------------------------------------------------------------------------------- 1 | import { MockElement } from '../test-support'; 2 | import { AttributeObjectSink } from './attribute-sink'; 3 | 4 | describe('Attribute Sink', () => { 5 | 6 | describe('Given an attribute object', () => { 7 | 8 | it('sets attributes on sink', () => { 9 | const el = MockElement(); 10 | const sink = AttributeObjectSink(el); 11 | 12 | sink({ 13 | 'data-foo': 'bar', 14 | 'readonly': 'readonly', 15 | 'class': 'class1', 16 | }); 17 | 18 | expect(el.readOnly).toBe('readonly'); 19 | expect(el.className).toContain('class1'); 20 | expect(el.dataset).toHaveProperty('foo', 'bar'); 21 | }); 22 | 23 | it('clears falsey attributes on sink', () => { 24 | const el = MockElement(); 25 | const sink = AttributeObjectSink(el); 26 | 27 | el.setAttribute('data-foo', 'bar'); 28 | el.setAttribute('readonly', 'readonly'); 29 | el.setAttribute('custom-attribute', 'custom-value'); 30 | 31 | sink({ 32 | 'data-foo': false, 33 | 'readonly': false, 34 | 'custom-attribute': null, 35 | }); 36 | 37 | expect(el.readOnly).not.toBe('readonly'); 38 | expect(el.getAttribute('custom-attribute')).toBeUndefined(); 39 | expect(el.dataset).not.toHaveProperty('foo', 'bar'); 40 | }); 41 | 42 | }); 43 | 44 | }); 45 | -------------------------------------------------------------------------------- /src/sinks/blur-sink.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd'; 2 | 3 | import type { SinkFunction } from '../types/sink'; 4 | import type { SinkBindingConfiguration } from "../types/internal"; 5 | import type { FocusableElement } from '../types/rml'; 6 | 7 | import { BlurSink, Blur } from './blur-sink'; 8 | 9 | 10 | const sinkFn = BlurSink(document.createElement('button')); 11 | expectType(sinkFn); 12 | 13 | const sink = Blur(document.createElement('button')); 14 | expectType>(sink); 15 | 16 | // expectType>(sink); // this should work, too, shouldn't it? 17 | 18 | -------------------------------------------------------------------------------- /src/sinks/blur-sink.test.ts: -------------------------------------------------------------------------------- 1 | import { MockElement } from '../test-support'; 2 | import { BlurSink } from './blur-sink'; 3 | 4 | describe('Blur Sink', () => { 5 | 6 | it('blurs the element on sink', () => { 7 | const el = MockElement(); 8 | let call1 = false; 9 | el.blur = () => call1 = true; 10 | 11 | const sink = BlurSink(el); 12 | sink(); 13 | 14 | expect(call1).toBe(true); 15 | }); 16 | 17 | }); 18 | 19 | -------------------------------------------------------------------------------- /src/sinks/blur-sink.ts: -------------------------------------------------------------------------------- 1 | import type { ExplicitSink, Sink, SinkElementTypes } from "../types/sink"; 2 | import type { RMLTemplateExpressions, SinkBindingConfiguration } from "../types/internal"; 3 | import type { FocusableElement } from '../types/rml'; 4 | 5 | import { SINK_TAG } from "../constants"; 6 | 7 | export const BLUR_SINK_TAG = 'blur'; 8 | 9 | export const BlurSink: Sink = (node: FocusableElement) => 10 | node.blur.bind(node) 11 | ; 12 | 13 | /** 14 | * A specialised sink for the "rml:blur" RML attribute 15 | * @param source A present or future boolean value 16 | * @returns RMLTemplateExpression A template expression for the "rml:blur" attribute 17 | * @example 18 | * @example 19 | * @example 20 | * @example 21 | */ 22 | export const Blur: ExplicitSink<'rml:blur'> = (source: RMLTemplateExpressions.BooleanAttributeValue<'rml:blur'>) => 23 | >({ 24 | type: SINK_TAG, 25 | t: BLUR_SINK_TAG, 26 | source, 27 | sink: BlurSink, 28 | }) 29 | ; 30 | 31 | -------------------------------------------------------------------------------- /src/sinks/checked-sink.test.ts: -------------------------------------------------------------------------------- 1 | import { MockElement } from '../test-support'; 2 | import { CheckedSink } from './checked-sink'; 3 | 4 | describe('Checked Sink', () => { 5 | 6 | describe('Given a boolean', () => { 7 | 8 | it('sets the the checked attribute on sink', () => { 9 | const el = MockElement(); 10 | const sink = CheckedSink(el); 11 | 12 | sink(true); 13 | expect(el.checked).toEqual(true); 14 | }); 15 | 16 | it('clears the the readonly attribute on falsy', () => { 17 | const el = MockElement(); 18 | const sink = CheckedSink(el); 19 | 20 | sink(true); 21 | sink(false); 22 | expect(el.checked).not.toEqual(true); 23 | 24 | sink(true); 25 | sink(undefined); 26 | expect(el.checked).not.toEqual(true); 27 | 28 | sink(true); 29 | sink(0); 30 | expect(el.checked).not.toEqual(true); 31 | }); 32 | 33 | }); 34 | 35 | }); 36 | -------------------------------------------------------------------------------- /src/sinks/checked-sink.ts: -------------------------------------------------------------------------------- 1 | import type { RMLTemplateExpressions, SinkBindingConfiguration } from "../types/internal"; 2 | import type { Sink, ExplicitSink } from "../types/sink"; 3 | 4 | import { SINK_TAG } from "../constants"; 5 | 6 | export const CHECKED_SINK_TAG = 'checked'; 7 | 8 | export const CheckedSink: Sink = (node: HTMLInputElement) => 9 | (checked: boolean) => { 10 | node.checked = checked 11 | }; 12 | 13 | /** 14 | * A specialised sink for the "checked" HTML attribute 15 | * @param source A present or future boolean value 16 | * @returns RMLTemplateExpression A template expression for the "checked" DOM attribute 17 | * @example 18 | * @example 19 | * @example 20 | * @example 21 | */ 22 | export const Checked: ExplicitSink<'checked'> = (source: RMLTemplateExpressions.BooleanAttributeValue<'checked'>) => 23 | >({ 24 | type: SINK_TAG, 25 | t: CHECKED_SINK_TAG, 26 | source, 27 | sink: CheckedSink, 28 | }) 29 | ; 30 | -------------------------------------------------------------------------------- /src/sinks/class-sink.test.ts: -------------------------------------------------------------------------------- 1 | import { MockElement } from '../test-support'; 2 | import { ClassObjectSink, ToggleClassSink } from './class-sink'; 3 | 4 | describe('Class Sink', () => { 5 | 6 | describe('Given a class object', () => { 7 | 8 | it('sets classes for truthy attributes on sink', () => { 9 | const el = MockElement(); 10 | const sink = ClassObjectSink(el); 11 | 12 | sink({ 13 | class1: true, 14 | class2: 1, 15 | class3: 'yes!', 16 | }); 17 | expect(el.className).toContain('class1'); 18 | expect(el.className).toContain('class2'); 19 | expect(el.className).toContain('class3'); 20 | }); 21 | 22 | it('clears classes for falsy attributes on sink', () => { 23 | const el = MockElement({ className: 'class1 class2 class3' }); 24 | const sink = ClassObjectSink(el); 25 | expect(el.className).toContain('class1'); 26 | expect(el.className).toContain('class2'); 27 | expect(el.className).toContain('class3'); 28 | 29 | sink({ 30 | class1: false, 31 | class2: 0, 32 | class3: '', 33 | }); 34 | expect(el.className).not.toContain('class1'); 35 | expect(el.className).not.toContain('class2'); 36 | expect(el.className).not.toContain('class3'); 37 | }); 38 | 39 | }); 40 | 41 | }); 42 | 43 | describe('Class Toggle Sink', () => { 44 | 45 | describe('Given a class object', () => { 46 | 47 | it('sets classes for truthy value on sink', () => { 48 | const el = MockElement(); 49 | const sink = ToggleClassSink('class1')(el); 50 | 51 | sink(true); 52 | expect(el.className).toContain('class1'); 53 | }); 54 | 55 | it('clears classes for falsy values on sink', () => { 56 | const el = MockElement({ className: 'class1' }); 57 | const sink = ToggleClassSink('class1')(el); 58 | 59 | sink(false); 60 | expect(el.className).not.toContain('class1'); 61 | }); 62 | 63 | }); 64 | 65 | }); 66 | 67 | 68 | ////////////////////////////////////// 69 | 70 | // it('sets classes for properties set to true on sink', () => { 71 | // const el = MockElement(); 72 | // const sink = ClassObjectSink(el); 73 | 74 | // sink({ 75 | // class1: true, 76 | // }); 77 | // expect(el.className).toContain('class1'); 78 | // }); 79 | 80 | // it('clears classes for properties set to false on sink', () => { 81 | // const el = MockElement({ className: 'class1 class2 class3' }); 82 | // const sink = ClassObjectSink(el); 83 | // sink({ 84 | // class1: false, 85 | // }); 86 | // expect(el.className).not.toContain('class1'); 87 | // expect(el.className).toContain('class2'); 88 | // expect(el.className).toContain('class3'); 89 | // }); 90 | 91 | // it('sets classes for properties set to 1 on sink', () => { 92 | // const el = MockElement(); 93 | // const sink = ClassObjectSink(el); 94 | 95 | // sink({ 96 | // class1: 1, 97 | // }); 98 | // expect(el.className).toContain('class1'); 99 | // }); 100 | 101 | // it('clears classes for properties set to -1 on sink', () => { 102 | // const el = MockElement({ className: 'class1 class2 class3' }); 103 | // const sink = ClassObjectSink(el); 104 | 105 | // sink({ 106 | // class1: -1, 107 | // }); 108 | // expect(el.className).not.toContain('class1'); 109 | // expect(el.className).toContain('class2'); 110 | // expect(el.className).toContain('class3'); 111 | // }); 112 | 113 | // it('toggles classes for properties set to 0 on sink', () => { 114 | // const el = MockElement({ className: 'class1 class2 class3' }); 115 | // const sink = ClassObjectSink(el); 116 | // sink({ 117 | // class1: 0, 118 | // }); 119 | // expect(el.className).not.toContain('class1'); 120 | // sink({ 121 | // class1: 0, 122 | // }); 123 | // expect(el.className).toContain('class1'); 124 | // }); 125 | -------------------------------------------------------------------------------- /src/sinks/class-sink.ts: -------------------------------------------------------------------------------- 1 | import type { CSSClassName } from "../types/style"; 2 | import type { RMLTemplateExpressions, SinkBindingConfiguration } from "../types/internal"; 3 | import type { Sink, ExplicitSink } from "../types/sink"; 4 | 5 | import { SINK_TAG } from "../constants"; 6 | import { asap } from "../lib/drain"; 7 | 8 | export const TOGGLE_CLASS_SINK_TAG = 'ToggleClass'; 9 | export const CLASS_SINK_TAG = 'class'; // Keeping it same as 'class" attribute for now. Don't change yet... 10 | 11 | export type ClassRecord = Record; 12 | export type ClassName = string; 13 | export type ElementsSupportingClass = Record; 14 | 15 | export const ToggleClassSink = (className: ClassName): Sink => 16 | (node: HTMLElement | SVGElement) => 17 | node.classList.toggle.bind(node.classList, className) 18 | ; 19 | 20 | export const ClassNameSink: Sink = (node: HTMLElement) => 21 | (str: CSSClassName) => 22 | node.className = str 23 | ; 24 | 25 | export const ClassObjectSink: Sink = (node: Element) => { 26 | const cl = node.classList; 27 | const set = (str: string) => node.className = str; 28 | const add = cl.add.bind(cl); 29 | const remove = cl.remove.bind(cl); 30 | const toggle = cl.toggle.bind(cl); 31 | 32 | return (name: ClassName | ClassRecord | ((ClassName | ClassRecord)[])) => { 33 | typeof name == 'string' 34 | ? name.includes(' ') 35 | ? set(name) 36 | : add(name) 37 | // FIXME: is it safe to assume it's an object, at this point? 38 | : (<(ClassName | ClassRecord)[]>[]).concat(name).forEach(obj => Object.entries(obj) 39 | // TODO: support 3-state with toggle 40 | .forEach(([k, v]) => asap(v ? add : remove, k)) 41 | ) 42 | }; 43 | }; 44 | 45 | ////////////////////////// 46 | export const ExperimentalClassObjectSink: Sink = (node: Element) => { 47 | const cl = node.classList; 48 | const add = cl.add.bind(cl); 49 | const remove = cl.remove.bind(cl); 50 | const toggle = cl.toggle.bind(cl); 51 | 52 | const actions = new Map void>([ 53 | [true, add], 54 | [false, remove], 55 | [undefined, remove], 56 | [-1, remove], 57 | [0, toggle], 58 | [NaN, toggle], 59 | [1, add], 60 | ]); 61 | 62 | return (name: ClassName | ClassRecord) => { 63 | typeof name == 'string' 64 | ? add(name) 65 | // FIXME: is it safe to assume it's an object, at this point? 66 | : Object.entries(name ?? {}) 67 | // .forEach(([k, v]) => v ? add(k) : remove(k)); 68 | .forEach(([k, v]) => actions.get(v)?.(k)); 69 | }; 70 | }; 71 | 72 | /** 73 | * A specialised sink to toggle individual classes on a given element 74 | * 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. 75 | * @param source A present or future string 76 | * @param className The class name to toggle 77 | * @returns RMLTemplateExpression A template expression for the "className" attribute 78 | * @example
79 | * @example
80 | **/ 81 | export const ToggleClass: ExplicitSink<'class'> = 82 | (source: RMLTemplateExpressions.Any, className: CSSClassName) => 83 | >({ 84 | type: SINK_TAG, 85 | t: TOGGLE_CLASS_SINK_TAG, 86 | source, 87 | sink: ToggleClassSink(className), 88 | }) 89 | ; 90 | 91 | /** 92 | * A specialised sink for the "class" HTML attribute 93 | * Will set the whole className of an element to the string emitted by the source 94 | * @param source A present or future string 95 | * @returns RMLTemplateExpression A template expression for the "className" attribute 96 | * @example
97 | * @example
98 | * @example
99 | **/ 100 | export const ClassName: ExplicitSink<'class'> = (source: RMLTemplateExpressions.ClassName) => 101 | >({ 102 | type: SINK_TAG, 103 | t: 'ClassName', 104 | source, 105 | sink: ClassNameSink, 106 | }) 107 | ; 108 | -------------------------------------------------------------------------------- /src/sinks/closed-sink.ts: -------------------------------------------------------------------------------- 1 | import type { RMLTemplateExpressions, SinkBindingConfiguration } from "../types/internal"; 2 | import type { ExplicitSink, Sink } from "../types/sink"; 3 | 4 | import { SINK_TAG } from "../constants"; 5 | 6 | export const CLOSED_SINK_TAG = 'closed'; 7 | 8 | /** 9 | * A sink that closes a <dialog> element when a source streams emits 10 | * @param dialogBox an HTMLDialogElement 11 | * @returns 12 | */ 13 | export const ClosedSink: Sink = (dialogBox: HTMLDialogElement) => 14 | dialogBox.close.bind(dialogBox) 15 | ; 16 | 17 | /** 18 | * An explicit sink that closes a <dialog> element when a source streams emits a non falsey value 19 | * 20 | * You can call this sink in the following ways: 21 | * - implicitly, by assigning it to the `rml:closed` attribute of a `` element 22 | * - explicitly, by using the `Closed` sink 23 | * - via a {@link Mixin}, by emitting a `rml:closed` key-value pair 24 | * 25 | * @returns Sink 26 | * 27 | * ## Examples 28 | * 29 | * ### Close a <dialog> box when a button is clicked 30 | * 31 | * ```ts 32 | * import { Subject } from 'rxjs'; 33 | * import { rml } from 'rimmel'; 34 | * 35 | * const Component = () => { 36 | * const close = new Subject(); 37 | * 38 | * return rml` 39 | * 40 | * 41 | * 42 | * `; 43 | * } 44 | * ``` 45 | * 46 | * ### Close a <dialog> box in 10s or when a button is clicked 47 | * 48 | * ```ts 49 | * import { Subject, merge } from 'rxjs'; 50 | * import { rml } from 'rimmel'; 51 | * 52 | * const Component = () => { 53 | * const close = new Subject(); 54 | * const timeout = new Promise(resolve => setTimeout(resolve, 10000)); 55 | * 56 | * return rml` 57 | * 58 | * 59 | * 60 | * `; 61 | * } 62 | * ``` 63 | * ### Close a <dialog> box from a {@link Mixin} 64 | * 65 | * ```ts 66 | * import { Subject } from 'rxjs'; 67 | * import { rml } from 'rimmel'; 68 | * 69 | * const Autoclose = () => { 70 | * const timeout = new Promise(resolve => setTimeout(resolve, 10000)); 71 | * 72 | * return { 73 | * 'rml:closed': timeout, 74 | * }; 75 | * }; 76 | * 77 | * const Component = () => rml` 78 | * 79 | * this box will close in 10s 80 | * 81 | * `; 82 | * } 83 | * ``` 84 | */ 85 | export const Closed: ExplicitSink<'closed'> = (source: RMLTemplateExpressions.BooleanAttributeValue<'closed'>) => 86 | >({ 87 | type: SINK_TAG, 88 | t: CLOSED_SINK_TAG, 89 | source, 90 | sink: ClosedSink, 91 | }) 92 | ; 93 | -------------------------------------------------------------------------------- /src/sinks/content-sink.ts: -------------------------------------------------------------------------------- 1 | import type { ExplicitSink, Sink } from "../types/sink"; 2 | import type { RMLTemplateExpressions, SinkBindingConfiguration } from "../types/internal"; 3 | import type { MaybeFuture } from "../types/futures"; 4 | 5 | import { SINK_TAG } from "../constants"; 6 | import { asap } from "../lib/drain"; 7 | 8 | export const AnyContentSink: Sink = (node: Element) => 9 | (htmlSource: MaybeFuture) => { 10 | asap((html: string) => node.innerHTML = html, htmlSource) 11 | } 12 | ; 13 | 14 | export const NodeValueSink: Sink = (node: Node) => 15 | (str: string) => { 16 | node.nodeValue = str 17 | } 18 | ; 19 | 20 | /** 21 | * A specialised sink to set the nodeValue on a node 22 | * @param source A present or future string 23 | * @returns RMLTemplateExpression A text-node RML template expression 24 | * @example
${NodeValue(stream)}
25 | */ 26 | export const NodeValue: ExplicitSink<'text'> = (source: RMLTemplateExpressions.StringLike) => 27 | >({ 28 | type: SINK_TAG, 29 | t: 'NodeValue', 30 | source: source, 31 | sink: NodeValueSink, 32 | }) 33 | ; 34 | 35 | -------------------------------------------------------------------------------- /src/sinks/dataset-sink.test.ts: -------------------------------------------------------------------------------- 1 | import { MockElement } from '../test-support'; 2 | import { DatasetSink, DatasetObjectSink } from './dataset-sink'; 3 | 4 | describe('Dataset Sink', () => { 5 | 6 | describe('Given a key', () => { 7 | 8 | it('sets data on sink', () => { 9 | const el = MockElement(); 10 | const sink = DatasetSink(el, 'key1'); 11 | 12 | sink('value1'); 13 | expect(el.dataset.key1).toEqual('value1'); 14 | }); 15 | 16 | }); 17 | 18 | }); 19 | 20 | describe('Dataset Object Sink', () => { 21 | 22 | describe('Given a dataset object', () => { 23 | 24 | it('sets data on sink', () => { 25 | const el = MockElement(); 26 | const sink = DatasetObjectSink(el); 27 | 28 | sink({ 29 | key1: 'value1', 30 | key2: 'value2', 31 | }); 32 | expect(el.dataset.key1).toEqual('value1'); 33 | expect(el.dataset.key2).toEqual('value2'); 34 | }); 35 | 36 | it('clears undefined data on sink', () => { 37 | const el = MockElement({ 38 | dataset: { 39 | key1: 'value1', 40 | key2: 'value2', 41 | } 42 | }); 43 | const sink = DatasetObjectSink(el); 44 | 45 | sink({ 46 | key2: undefined, 47 | }); 48 | expect(el.dataset.key1).toEqual('value1'); 49 | expect(el.dataset.key2).toBeUndefined(); 50 | }); 51 | 52 | }); 53 | 54 | }); 55 | -------------------------------------------------------------------------------- /src/sinks/dataset-sink.ts: -------------------------------------------------------------------------------- 1 | import type { Sink } from "../types/sink"; 2 | 3 | import { asap } from "../lib/drain"; 4 | 5 | type DatasetKey = string; 6 | 7 | export const DatasetSink: Sink = (node: HTMLElement, key: DatasetKey) => { 8 | const { dataset } = node; 9 | return (str: string) => { 10 | dataset[key] = str; 11 | }; 12 | }; 13 | 14 | export const DatasetItemPreSink = (key: DatasetKey): Sink => 15 | (node: HTMLElement) => { 16 | const { dataset } = node; 17 | return (str: string) => { 18 | dataset[key] = str; 19 | }; 20 | } 21 | ; 22 | 23 | export const DatasetObjectSink: Sink = (node: HTMLElement | SVGElement | MathMLElement) => { 24 | const { dataset } = node; 25 | return (data: Record) => { 26 | for (const [key, str] of Object.entries(data ?? {})) { 27 | (str === undefined || str == null) 28 | ? delete dataset[key] 29 | : asap((str: string) => dataset[key] = str, str); 30 | } 31 | }; 32 | }; -------------------------------------------------------------------------------- /src/sinks/disabled-sink.test.ts: -------------------------------------------------------------------------------- 1 | import { MockElement } from '../test-support'; 2 | import { DisabledSink } from './disabled-sink'; 3 | 4 | describe('disabled Sink', () => { 5 | 6 | describe('Given a boolean', () => { 7 | 8 | it('sets the the readonly attribute on sink', () => { 9 | const el: Partial = MockElement(); 10 | const sink = DisabledSink(el as HTMLInputElement); 11 | 12 | sink(true); 13 | expect(el.disabled).toEqual(true); 14 | }); 15 | 16 | it('clears the the readonly attribute on falsy', () => { 17 | const el: Partial = MockElement(); 18 | const sink = DisabledSink(el as HTMLInputElement); 19 | 20 | sink(true); 21 | sink(false); 22 | expect(el.disabled).not.toEqual(true); 23 | 24 | sink(true); 25 | sink(undefined); 26 | expect(el.disabled).not.toEqual(true); 27 | 28 | sink(true); 29 | sink(0); 30 | expect(el.disabled).not.toEqual(true); 31 | }); 32 | 33 | }); 34 | 35 | }); 36 | -------------------------------------------------------------------------------- /src/sinks/disabled-sink.ts: -------------------------------------------------------------------------------- 1 | import type { ExplicitSink, Sink, SinkElementTypes } from "../types/sink"; 2 | import type { RMLTemplateExpressions, SinkBindingConfiguration } from "../types/internal"; 3 | 4 | import { SINK_TAG } from "../constants"; 5 | 6 | export const DISABLED_SINK_TAG = 'disabled'; 7 | 8 | /** 9 | * An Element supporting the "disabled" HTML attribute (i.e.: that can be disabled) 10 | */ 11 | type Disableable = SinkElementTypes["disabled"]["elements"]; 12 | 13 | export const DisabledSink: Sink = (node: Disableable) => 14 | (value: boolean) => { 15 | node.disabled = value; 16 | }; 17 | ; 18 | 19 | /** 20 | * A specialised sink for the "disabled" HTML attribute 21 | * @param source A present or future boolean value 22 | * @returns RMLTemplateExpression A template expression for the "disabled" attribute 23 | * @example 24 | * @example 25 | * @example 26 | * @example 27 | */ 28 | export const Disabled: ExplicitSink<'disabled'> = (source: RMLTemplateExpressions.BooleanAttributeValue<'disabled'>) => 29 | >({ 30 | type: SINK_TAG, 31 | t: DISABLED_SINK_TAG, 32 | source, 33 | sink: DisabledSink, 34 | }) 35 | ; 36 | 37 | -------------------------------------------------------------------------------- /src/sinks/error-sink.test.ts: -------------------------------------------------------------------------------- 1 | import { MockElement } from '../test-support'; 2 | import { Catch } from './error-sink'; 3 | 4 | // describe.skip('Error Handler', () => { 5 | 6 | // describe('When an error is raised in a stream', () => { 7 | 8 | // it('logs it to the console', () => { 9 | // const message = 'test error'; 10 | 11 | // const e = console.error 12 | // console.error = jest.fn(); 13 | // const h = errorHandler; 14 | 15 | // h(new Error(message)); 16 | // expect(console.error).toHaveBeenCalledWith(message); 17 | // console.error = e; 18 | // }); 19 | 20 | // }); 21 | 22 | // }); 23 | 24 | describe('Catch Sink', () => { 25 | 26 | it('Catches errors as innerHTML', async () => { 27 | const el = MockElement(); 28 | const expectedString = 'foo'; 29 | const stream = Promise.reject('failure'); 30 | const sink = Catch(stream, () => expectedString); 31 | 32 | expect(await sink).toEqual(expectedString); 33 | }); 34 | 35 | }); 36 | -------------------------------------------------------------------------------- /src/sinks/error-sink.ts: -------------------------------------------------------------------------------- 1 | import { catchError, of } from 'rxjs'; 2 | import type { HTMLString } from '../types/dom'; 3 | import { MaybeFuture } from '../types/futures'; 4 | 5 | /** 6 | * An error-catcher sink that displays a custom message when the underlying stream errors 7 | * @param stream The original data or source stream 8 | * @param handler A function that takes an error and returns a message 9 | * @returns RMLTemplateExpression A template expression for the "innerHTML" attribute 10 | * @example
${Catch(contentStream)}"> 11 | * @example 12 | */ 13 | export const Catch = (stream: MaybeFuture, handler: (e: Error) => HTMLString | string | number) => 14 | (stream.pipe?.(catchError((err: Error) => of(handler(err))))) 15 | ?? (stream.catch?.((err: Error) => handler(err))) 16 | ?? stream 17 | // ?? handler(stream) 18 | ; 19 | -------------------------------------------------------------------------------- /src/sinks/event-handler-sink.ts: -------------------------------------------------------------------------------- 1 | import type { HTMLEventName } from "../types/dom"; 2 | import type { Sink } from "../types/sink"; 3 | 4 | export const EventHandlerSink: Sink = (node: HTMLElement, e: HTMLEventName) => 5 | // FIXME: wrap h and options in an object, as nothing will emit both values! 6 | // TODO: use addListener() instead of this 7 | (h: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => { 8 | node.addEventListener(e, h, options); 9 | // ? node.removeEventListener(e, h) // FIXME: to be able to remove an event listener, will need to store a reference to it first! 10 | // : node.addEventListener(e, h) 11 | }; 12 | -------------------------------------------------------------------------------- /src/sinks/focus-sink.test.ts: -------------------------------------------------------------------------------- 1 | import { MockElement } from '../test-support'; 2 | import { FocusSink } from './focus-sink'; 3 | 4 | describe('Focus Sink', () => { 5 | 6 | it('focuses the element when a truthy value is sinked', () => { 7 | const el = MockElement(); 8 | let call1 = false; 9 | let call2 = false; 10 | el.focus = () => call1 = true; 11 | el.blur = () => call2 = true; 12 | 13 | const sink = FocusSink(el); 14 | sink(true); 15 | 16 | expect(call1).toBe(true); 17 | expect(call2).toBe(false); 18 | }); 19 | 20 | it('blurs the element when a falsy value is sinked', () => { 21 | const el = MockElement(); 22 | let call1 = false; 23 | let call2 = false; 24 | el.focus = () => call1 = true; 25 | el.blur = () => call2 = true; 26 | 27 | const sink = FocusSink(el); 28 | sink(false); 29 | 30 | expect(call1).toBe(false); 31 | expect(call2).toBe(true); 32 | }); 33 | 34 | }); 35 | 36 | -------------------------------------------------------------------------------- /src/sinks/focus-sink.ts: -------------------------------------------------------------------------------- 1 | import type { ExplicitSink, Sink } from "../types/sink"; 2 | import type { RMLTemplateExpressions, SinkBindingConfiguration } from "../types/internal"; 3 | import type { FocusableElement } from '../types/rml'; 4 | 5 | import { SINK_TAG } from "../constants"; 6 | 7 | export const FOCUS_SINK_TAG = 'focus'; 8 | 9 | export const FocusSink: Sink = (node: FocusableElement) => 10 | (state: boolean) => { 11 | state ? node.focus?.() : node.blur?.(); 12 | }; 13 | ; 14 | 15 | /** 16 | * A specialised sink for the "rml:focus" RML attribute 17 | * @param source A present or future boolean value 18 | * @returns RMLTemplateExpression A template expression for the "rml:focus" RML attribute 19 | * @example 20 | * @example 21 | * @example 22 | * @example 23 | */ 24 | export const Focus: ExplicitSink<'rml:focus'> = (source: RMLTemplateExpressions.BooleanAttributeValue<'rml:focus'>) => 25 | >({ 26 | type: SINK_TAG, 27 | t: FOCUS_SINK_TAG, 28 | source, 29 | sink: FocusSink, 30 | }) 31 | ; 32 | -------------------------------------------------------------------------------- /src/sinks/hidden-sink.ts: -------------------------------------------------------------------------------- 1 | import type { RMLTemplateExpressions, SinkBindingConfiguration } from "../types/internal"; 2 | import type { Sink, ExplicitSink } from "../types/sink"; 3 | 4 | import { SINK_TAG } from "../constants"; 5 | 6 | export const HIDDEN_SINK_TAG = 'hidden'; 7 | 8 | export const HiddenSink: Sink = (node: HTMLElement) => 9 | (hidden: boolean) => { 10 | node.hidden = hidden 11 | }; 12 | 13 | /** 14 | * A specialised sink for the "hidden" HTML attribute 15 | * @param source A present or future boolean value 16 | * @returns RMLTemplateExpression A template expression for the "hidden" DOM attribute 17 | * @example