diff --git a/README.md b/README.md index 94a1897..b396260 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,60 @@ FullName.propTypes = { register(FullName, 'full-name'); ``` +### Custom Events + +If you want to be able to emit custom events from your web component then you can add a `customEvents` object to the options. +Alternatively they can be supplied on the component. The events will be added to the props. They are async (Promise) methods +that the outside can respond to via a callback. Whatever you pass in to the method will be the `payload` in the event detail. + +```js +function MyAsyncComponent({ onError, onLoaded, src }) { + const [posts, setPosts] = useState(null); + + useEffect(() => { + if (!src) return; + axios.get(src).then(res => { + setPosts(res.data); + onLoaded(`Loaded ${res.data.length} posts`) + .then(res => { + console.log('got ack from host, do something...', res); + }); + }, onError); + }, [src]); + + return ( +
+ { posts ? + posts.map(post => ) : + Loading... + } +
+ ); +} +register(MyAsyncComponent, 'x-my-async', ['src'], { + customEvents: { onLoaded: 'loaded', onError: 'error' } +}); +``` + +Later in the consuming HTML page: + +```html + + +``` ## Related diff --git a/src/index.js b/src/index.js index 42318d7..64bef15 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,24 @@ export default function register(Component, tagName, propNames, options) { inst._vdomComponent = Component; inst._root = options && options.shadow ? inst.attachShadow({ mode: 'open' }) : inst; + + inst._customEvents = {}; + const customEvents = + (options && options.customEvents) || Component.customEvents; + if (customEvents) { + Object.keys(customEvents).forEach((eventName) => { + const emitName = customEvents[eventName] || eventName; + const handler = (payload) => inst.dispatch(emitName, payload); + // later to propagate to props + inst._customEvents[eventName] = handler; + Object.defineProperty(inst, eventName, { + get() { + return handler; + }, + }); + }); + } + return inst; } PreactElement.prototype = Object.create(HTMLElement.prototype); @@ -13,6 +31,7 @@ export default function register(Component, tagName, propNames, options) { PreactElement.prototype.connectedCallback = connectedCallback; PreactElement.prototype.attributeChangedCallback = attributeChangedCallback; PreactElement.prototype.disconnectedCallback = disconnectedCallback; + PreactElement.prototype.dispatch = dispatch; propNames = propNames || @@ -78,7 +97,7 @@ function connectedCallback() { this._vdom = h( ContextProvider, - { ...this._props, context }, + { ...this._props, ...this._customEvents, context }, toVdom(this, this._vdomComponent) ); (this.hasAttribute('hydrate') ? hydrate : render)(this._vdom, this._root); @@ -161,3 +180,21 @@ function toVdom(element, nodeName) { const wrappedChildren = nodeName ? h(Slot, null, children) : children; return h(nodeName || element.nodeName.toLowerCase(), props, wrappedChildren); } + +function dispatch(eventName, payload) { + return new Promise((resolve, reject) => { + const callback = (result, error) => { + if (error !== undefined) { + reject(error); + return; + } + resolve(result); + }; + this.dispatchEvent( + new CustomEvent(eventName, { + bubbles: true, + detail: { callback, payload }, + }) + ); + }); +} diff --git a/src/index.test.jsx b/src/index.test.jsx index 00da646..d29ba26 100644 --- a/src/index.test.jsx +++ b/src/index.test.jsx @@ -132,6 +132,118 @@ describe('web components', () => { }); }); + describe('Custom Events', () => { + function DummyEvented({ onMyEvent, onMyEventSuccess, onMyEventFailed }) { + function clickHandler() { + onMyEvent('payload').then(onMyEventSuccess, onMyEventFailed); + } + return ( +
+ +
+ ); + } + const onMyEvent = 'myEvent'; + const onMyEventSuccess = 'myEventSuccess'; + const onMyEventFailed = 'myEventFailed'; + DummyEvented.customEvents = { + onMyEvent, + onMyEventSuccess, + onMyEventFailed, + }; + registerElement(DummyEvented, 'x-dummy-evented', [], { + customEvents: { onMyEvent, onMyEventSuccess, onMyEventFailed }, + }); + + registerElement(DummyEvented, 'x-dummy-evented1'); + + it('should allow you to expose custom events', async () => { + let done; + const promise = new Promise((resolve) => { + done = resolve; + }); + const el = document.createElement('x-dummy-evented'); + root.appendChild(el); + + el.addEventListener(onMyEvent, (e) => { + assert.equal(e.detail.payload, 'payload'); + done(); + }); + + act(() => { + el.querySelector('button').click(); + return promise; + }); + }); + + it('should enable async events (resolved)', async () => { + let done; + const promise = new Promise((resolve) => { + done = resolve; + }); + const el = document.createElement('x-dummy-evented'); + root.appendChild(el); + + el.addEventListener(onMyEvent, (e) => { + const callback = e.detail.callback; + callback('success'); + }); + + el.addEventListener(onMyEventSuccess, (e) => { + assert.equal(e.detail.payload, 'success'); + done(); + }); + + act(() => { + el.querySelector('button').click(); + return promise; + }); + }); + + it('should enable async events (rejected)', async () => { + let done; + const promise = new Promise((resolve) => { + done = resolve; + }); + const el = document.createElement('x-dummy-evented'); + root.appendChild(el); + + el.addEventListener(onMyEvent, (e) => { + const callback = e.detail.callback; + callback(null, 'failed!'); + }); + + el.addEventListener(onMyEventFailed, (e) => { + assert.equal(e.detail.payload, 'failed!'); + done(); + }); + + act(() => { + el.querySelector('button').click(); + return promise; + }); + }); + + it('should allow you to expose custom events via the static property', async () => { + let done; + const promise = new Promise((resolve) => { + done = resolve; + }); + const el = document.createElement('x-dummy-evented1'); + root.appendChild(el); + + el.addEventListener(onMyEvent, (e) => { + assert.equal(e.detail.payload, 'payload'); + done(); + }); + + act(() => { + el.querySelector('button').click(); + return promise; + }); + }); + }); + function Foo({ text, children }) { return (