├── .gitignore ├── LICENSE ├── README.md ├── live_elements_controller.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sam Ruby 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @flydotio/stimulus-live-elements 2 | 3 | See [announcement](https://fly.io/ruby-dispatch/introducing-live-elements/) and [demo](https://github.com/fly-apps/live-elements-demo/blob/main/README.md#readme) for more information. 4 | 5 | **Status: ALPHA** 6 | 7 | API may change dramatically before release. For now, pin a specific version. 8 | 9 | ## Usage 10 | 11 | See [gist](https://gist.github.com/rubys/2f94bffcd369f1c014fef35fd355beba) or 12 | run the following commang to get started: 13 | 14 | ```sh 15 | bin/importmap pin @flydotio/stimulus-live-elements@0.1.0 16 | echo 'export { default } from "@flydotio/stimulus-live-elements"' > \ 17 | app/javascript/controllers/live_elements_controller.js 18 | ``` 19 | 20 | Add `data-controller="live-elements"` to your containing HTML element. 21 | For example, if you are using a Rails form: 22 | 23 | ```erb 24 | <%= form_with data: {controller: "live-elements"} do |form| %> 25 | ``` 26 | 27 | Within that containing element, you can associate DOM events with 28 | Rails actions by adding `data-action` attributes. For example, 29 | to cause a button clike to invoke a demo#click controller action 30 | on the server, do something like the following: 31 | 32 | ```erb 33 | <%= form.button "blue", name: 'color', 34 | data: {action: {click: demo_click_path}} 35 | %> 36 | ``` 37 | 38 | In your server, produce a [turbostream](https://turbo.hotwired.dev/handbook/streams) response. An example of a response that replaces a header: 39 | 40 | ```ruby 41 | respond_to do |format| 42 | format.turbo_stream { 43 | render turbo_stream: turbo_stream.replace('header', 44 | render_to_string(partial: 'header', locals: {color: color})) 45 | } 46 | end 47 | ``` 48 | -------------------------------------------------------------------------------- /live_elements_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | // Connects to data-controller="live-elements" 4 | export default class extends Controller { 5 | connect() { 6 | // extract CSRF token for later use in building fetch headers 7 | this.token = document.querySelector( 8 | 'meta[name="csrf-token"]' 9 | ).content; 10 | 11 | this.queue = Promise.resolve(); 12 | 13 | // monitor this element for actions 14 | this.monitor(this.element); 15 | 16 | // when nodes are added, monitor them too 17 | this.observer = new MutationObserver(mutationsList => { 18 | mutationsList.forEach(mutation => { 19 | mutation.addedNodes.forEach(addedNode => { 20 | if (addedNode.nodeType == Node.ELEMENT_NODE) { 21 | this.monitor(addedNode); 22 | } 23 | }); 24 | }); 25 | }); 26 | 27 | this.observer.observe(this.element, { subtree: true, childList: true }); 28 | } 29 | 30 | disconnect() { 31 | this.observer.disconnect(); 32 | } 33 | 34 | // find all data-action attributes and serialize event execution 35 | monitor(root) { 36 | for (let element of root.querySelectorAll("*[data-action]")) { 37 | for (let [type, path] of Object.entries(JSON.parse(element.dataset.action))) { 38 | element.addEventListener(type, event => { 39 | event.preventDefault(); 40 | 41 | this.queue = this.queue.then(() => new Promise((resolve, reject) => { 42 | this.execute(element, path, event, resolve); 43 | })) 44 | }) 45 | } 46 | } 47 | } 48 | 49 | // process actions by building form parameters, executing a fetch request, 50 | // and processing results as a turbostream 51 | execute(element, path, event, resolve) { 52 | let method = "post"; 53 | let form; 54 | let body = null; 55 | 56 | // if this element is assocaited with a form, get the form data to submit 57 | if (element.form) { 58 | method = element.form.method; 59 | form = new FormData(element.form); 60 | } else { 61 | form = new FormData(); 62 | } 63 | 64 | // if a button was pressed, add it to the data to submit 65 | if (element.nodeName == 'BUTTON') { 66 | form.append(element.name, element.textContent); 67 | } 68 | 69 | // for get requests, set search parameters, otherwise set body 70 | if (method.toLowerCase() == 'get') { 71 | path = new URL(path, window.location) 72 | for (let [key, value] of form.entries()) { 73 | path.searchParams.append(key, value); 74 | } 75 | } else { 76 | body = (new URLSearchParams(form)).toString() 77 | } 78 | 79 | // issue fetch request and process the response as a turbo stream 80 | fetch(path, { 81 | method: method, 82 | headers: { 83 | 'X-CSRF-Token': this.token, 84 | 'Accept': 'text/vnd.turbo-stream.html', 85 | 'Content-Type': 'application/x-www-form-urlencoded' 86 | }, 87 | credentials: 'same-origin', 88 | body: body 89 | }).then(response => response.text()) 90 | .then(html => Turbo.renderStreamMessage(html)) 91 | .finally(() => window.requestIdleCallback 92 | ? window.requestIdleCallback(resolve, { timeout: 100 }) 93 | : setTimeout(resolve, 50)); 94 | // 95 | // https://caniuse.com/requestidlecallback 96 | // 97 | // "Since Turbo Streams are customElements, 98 | // there's no way to know when they're finished executing" 99 | // -- https://github.com/rails/request.js/issues/35 100 | } catch (error) { 101 | console.error(error); 102 | resolve(); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flydotio/stimulus-live-elements", 3 | "version": "0.1.1", 4 | "description": "Live Element support for Rails via Stimulus", 5 | "author": "Sam Ruby ", 6 | "license": "MIT", 7 | "engines": { 8 | "node": ">=16.0.0" 9 | }, 10 | "main": "live_elements_controller.js", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/rubys/snowpack-plugin-require-context/" 14 | }, 15 | "publishConfig": { 16 | "access": "public" 17 | }, 18 | "dependencies": { 19 | "@hotwired/stimulus": ">=3.2.1" 20 | } 21 | } 22 | --------------------------------------------------------------------------------