├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .travis.yml ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── rollup.config.js ├── src └── phoenix-custom-event-hook.js └── test └── phx-custom-event-hook-test.js /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | # Runs on pushes targeting the default branch 5 | push: 6 | branches: ["master"] 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | - name: install npms 15 | run: npm install 16 | - name: Run tests 17 | run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry = "https://registry.npmjs.com/" 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '11' 4 | - '10' 5 | - '8' 6 | - '6' 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 [these people](https://github.com/rollup/rollup-starter-lib/graphs/contributors) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phoenix-custom-event-hook 2 | 3 | This package is a [Phoenix LiveView](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html) [hook](https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks) that allows you to easily send [Custom Events](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent) to your live view. 4 | 5 | ## Installation 6 | 7 | In your phoenix project assets directory 8 | 9 | ``` 10 | npm install phoenix-custom-event-hook 11 | ``` 12 | 13 | ## Usage 14 | 15 | 1. Add the PhoenixCustomEvent hook to your LiveSocket 16 | 17 | ```javascript 18 | import PhoenixCustomEvent from 'phoenix-custom-event-hook'; 19 | 20 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 21 | let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken }, hooks: { PhoenixCustomEvent } }) 22 | ``` 23 | 24 | 2. Add `phx-hook` and `phx-send-events` attributes to elements in your template. 25 | 26 | In this example, the `lit-google-element` emits a `bounds_changed` custom event which will become live_view event. The payload will be the detail of the custom event, merged 27 | with any `data-` attribute values (the event target dataset). This can be customized if needed (see below). 28 | 29 | ```html 30 | 31 | ``` 32 | 33 | 3. Handle the event in your live view 34 | 35 | ```elixir 36 | def handle_event( 37 | "bounds_changed", 38 | %{"north" => north, "east" => east, "west" => west, "south" => south}, 39 | socket 40 | ) do 41 | airports = 42 | Airports.list_airports_in_bounds(%{north: north, east: east, west: west, south: south}) 43 | 44 | {:noreply, socket |> assign(airports: airports)} 45 | end 46 | ``` 47 | 48 | An target component can be specified by assigning a component id to your custom element's `phx-target` attribute. In this example, any events emitted by the `lit-google-map` element will be handled by the LiveComponent that renders it, rather than the LiveView. 49 | 50 | ```html 51 | 52 | ``` 53 | 54 | Not currently supported: multiple event targets, targeting events by CSS selector. 55 | 56 | ## Loading events 57 | 58 | This hook will also dispatch the following events on the element it is added to: 59 | 60 | * `phx-event-start` when an event is sent to live view 61 | * `phx-event-complete` when a reply is received 62 | 63 | ## Receiving events 64 | 65 | If you wish to receive events from LiveView, add a `phx-receive-events` attribute to the element this hook is defined on which contains a list of events you wish to receive. Each event will become a CustomEvent of the same name with the `detail` property containing the payload. 66 | 67 | For example, in LiveView: 68 | 69 | ```elixir 70 | socket 71 | |> push_event("message_updated", %{message: "HI there"}) 72 | ``` 73 | 74 | In your Custom Element: 75 | 76 | ```javascript 77 | this.addEventListener("message_updated", ({ detail: { message } }) => { 78 | console.log(message); 79 | }); 80 | ``` 81 | 82 | ## Event serialization 83 | 84 | As of version 0.0.6, the payload for the event pushed to live view will contain: 85 | 86 | * the detail property of the custom event 87 | * the dataset from the event target 88 | 89 | This will be merged together into the payload sent to LiveView. If you wish to override this behaviour, you may define your own implemention of the `serializeEvent` function on the hook object, for example: 90 | 91 | ```js 92 | import PhoenixCustomEvent from 'phoenix-custom-event-hook'; 93 | PhoenixCustomEvent.serializeEvent = (_event) => { return {foo: 'bar' } }; 94 | ``` 95 | 96 | ## License 97 | 98 | [MIT](LICENSE). 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phoenix-custom-event-hook", 3 | "author": { 4 | "name": "Chris Nelson" 5 | }, 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/gaslight/phoenix-custom-event-hook.git" 9 | }, 10 | "version": "0.0.6", 11 | "main": "dist/phoenix-custom-event-hook.cjs.js", 12 | "module": "dist/phoenix-custom-event-hook.esm.js", 13 | "browser": "dist/phoenix-custom-event-hook.umd.js", 14 | "dependencies": { 15 | "ms": "^2.0.0" 16 | }, 17 | "devDependencies": { 18 | "@esm-bundle/chai": "^4.3.4-fix.0", 19 | "@open-wc/testing": "^3.1.7", 20 | "@rollup/plugin-commonjs": "^11.0.1", 21 | "@rollup/plugin-node-resolve": "^7.0.0", 22 | "@web/test-runner": "^0.15.1", 23 | "rollup": "^1.29.0", 24 | "sinon": "^15.0.1" 25 | }, 26 | "scripts": { 27 | "prepare": "npm run build", 28 | "build": "rollup -c", 29 | "prepublish": "npm run build", 30 | "dev": "rollup -c -w", 31 | "test": "web-test-runner \"test/*test.js\" --node-resolve", 32 | "pretest": "npm run build" 33 | }, 34 | "files": [ 35 | "dist" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import pkg from './package.json'; 4 | 5 | export default [ 6 | // browser-friendly UMD build 7 | { 8 | input: 'src/phoenix-custom-event-hook.js', 9 | output: { 10 | name: 'phoenix-custom-event-hook', 11 | file: pkg.browser, 12 | format: 'umd' 13 | }, 14 | plugins: [ 15 | resolve(), // so Rollup can find `ms` 16 | commonjs() // so Rollup can convert `ms` to an ES module 17 | ] 18 | }, 19 | 20 | // CommonJS (for Node) and ES module (for bundlers) build. 21 | // (We could have three entries in the configuration array 22 | // instead of two, but it's quicker to generate multiple 23 | // builds from a single configuration where possible, using 24 | // an array for the `output` option, where we can specify 25 | // `file` and `format` for each target) 26 | { 27 | input: 'src/phoenix-custom-event-hook.js', 28 | output: [ 29 | { file: pkg.main, format: 'cjs' }, 30 | { file: pkg.module, format: 'es' } 31 | ] 32 | } 33 | ]; 34 | -------------------------------------------------------------------------------- /src/phoenix-custom-event-hook.js: -------------------------------------------------------------------------------- 1 | const PhxCustomEvent = { 2 | 3 | listeners: [], 4 | 5 | serializeEvent(event) { 6 | const { detail, target: { dataset } } = event; 7 | return {...detail, ...dataset}; 8 | }, 9 | 10 | mounted() { 11 | const sendEvent = (eventName, phxEvent) => { 12 | const attrs = this.el.attributes; 13 | const phxTarget = attrs["phx-target"] && attrs["phx-target"].value; 14 | const pushEvent = phxTarget 15 | ? (event, payload, callback) => 16 | this.pushEventTo(phxTarget, event, payload, callback) 17 | : (event, payload, callback) => this.pushEvent(event, payload, callback); 18 | const listener = (evt) => { 19 | const payload = this.serializeEvent(evt); 20 | this.el.dispatchEvent(new CustomEvent('phx-event-start', { detail: { name: eventName, payload } })); 21 | pushEvent(phxEvent, payload, e => { 22 | this.el.dispatchEvent(new CustomEvent('phx-event-complete', { detail: { name: eventName, payload } })); 23 | }); 24 | }; 25 | this.el.addEventListener(eventName, listener); 26 | this.listeners.push({eventName, listener}); 27 | }; 28 | 29 | const attrs = this.el.attributes; 30 | for (var i = 0; i < attrs.length; i++) { 31 | if (/^phx-custom-event-/.test(attrs[i].name)) { 32 | const eventName = attrs[i].name.replace("phx-custom-event-", ""); 33 | const phxEvent = attrs[i].value; 34 | sendEvent(eventName, phxEvent); 35 | } 36 | } 37 | if (this.el.getAttribute("phx-send-events")) { 38 | const eventsToSend = this.el.getAttribute("phx-send-events").split(","); 39 | eventsToSend.forEach((eventName) => sendEvent(eventName, eventName)); 40 | } 41 | if (this.el.getAttribute("phx-receive-events")) { 42 | const phoenixEvents = this.el 43 | .getAttribute("phx-receive-events") 44 | .split(","); 45 | phoenixEvents.forEach((evt) => { 46 | this.handleEvent(evt, (payload) => { 47 | this.el.dispatchEvent(new CustomEvent(evt, { detail: payload })); 48 | }); 49 | }); 50 | } 51 | }, 52 | 53 | destroyed() { 54 | this.listeners.forEach(({eventName, listener}) => { 55 | this.el.removeEventListener(eventName, listener); 56 | }); 57 | } 58 | }; 59 | 60 | export default PhxCustomEvent; 61 | -------------------------------------------------------------------------------- /test/phx-custom-event-hook-test.js: -------------------------------------------------------------------------------- 1 | import { fixture } from '@open-wc/testing'; 2 | import sinon from 'sinon'; 3 | import { expect } from "@esm-bundle/chai"; 4 | import PhxCustomEventHook from '../src/phoenix-custom-event-hook'; 5 | 6 | describe('hook', () => { 7 | describe('mounted', () => { 8 | let element; 9 | beforeEach(async () => { 10 | element = await fixture(`
`); 11 | PhxCustomEventHook.el = element; 12 | PhxCustomEventHook.pushEvent = sinon.spy(); 13 | }); 14 | it('sends events', async () => { 15 | PhxCustomEventHook.mounted(); 16 | element.dispatchEvent(new CustomEvent('foo', {detail: {bing: 'baz'}})); 17 | expect(PhxCustomEventHook.pushEvent.args[0][0]).to.equal('foo'); 18 | expect(PhxCustomEventHook.pushEvent.args[0][1]).to.deep.equal({bing: 'baz', thing: 'wut'}); 19 | }); 20 | 21 | it('allows serialization to be overridden', () => { 22 | PhxCustomEventHook.serializeEvent = (_event) => {return {foo: 'bar'}}; 23 | PhxCustomEventHook.mounted(); 24 | element.dispatchEvent(new CustomEvent('foo', {detail: {bing: 'baz'}})); 25 | expect(PhxCustomEventHook.pushEvent.args[0][1]).to.deep.equal({foo: 'bar'}); 26 | }); 27 | }); 28 | 29 | describe('destroyed', () => { 30 | it('removes event listeners', async () => { 31 | const element = await fixture(`
`); 32 | PhxCustomEventHook.el = element; 33 | PhxCustomEventHook.pushEvent = sinon.spy(); 34 | PhxCustomEventHook.mounted(); 35 | PhxCustomEventHook.destroyed(); 36 | element.dispatchEvent(new CustomEvent('foo', {detail: {bing: 'baz'}})); 37 | expect(PhxCustomEventHook.pushEvent.called).to.be.false; 38 | }); 39 | }); 40 | 41 | }); --------------------------------------------------------------------------------