├── .babelrc ├── .gitignore ├── .npmignore ├── README.md ├── example ├── index.html ├── package.json ├── src │ ├── Item.ts │ ├── List.ts │ └── index.ts ├── tsconfig.json └── typings.json ├── package.json ├── src └── index.ts └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor-specific 2 | .idea 3 | *.sublime-project 4 | *.sublime-workspace 5 | .settings 6 | 7 | # Installed libs 8 | node_modules 9 | 10 | # Misc 11 | npm-debug.log 12 | .DS_Store 13 | ignore/ 14 | dist/ 15 | lib/ 16 | typings/ 17 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Editor-specific 2 | .idea 3 | *.sublime-project 4 | *.sublime-workspace 5 | .settings 6 | 7 | # Installed libs 8 | node_modules 9 | 10 | # Misc 11 | npm-debug.log 12 | .DS_Store 13 | ignore/ 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cycle.js `customElementify` 2 | 3 | ##### Experimental 4 | 5 | Helper function that takes a Cycle.js component (`(sources: Sources) => Sinks`) and returns a JavaScript class that can be registered as a Web Component custom element with `document.registerElement`: 6 | 7 | ```js 8 | import customElementify from 'cycle-custom-elementify'; 9 | 10 | function main(sources) { 11 | // ... 12 | } 13 | 14 | const customElementClass = customElementify(main); 15 | document.registerElement('my-web-component', { prototype: customElementClass }); 16 | ``` 17 | 18 | ```html 19 | 20 | ``` 21 | 22 | ## Installation 23 | 24 | ``` 25 | npm install cycle-custom-elementify 26 | ``` 27 | 28 | #### Required! 29 | 30 | Your target browser must support [Custom Elements v0](http://webcomponents.org/polyfills/custom-elements/) or install the polyfill for other browsers: 31 | 32 | - `npm install webcomponents.js` 33 | - Cycle DOM v12.2.4 34 | - [This Snabbdom Pull Request](https://github.com/paldepind/snabbdom/pull/159) needs to be applied before you can use this library 35 | - Include `` in your page 36 | 37 | This library is experimental and so far **only** supports Cycle.js apps written with xstream. You can only `customElementify` a function that expects xstream sources and sinks. 38 | 39 | ## Usage 40 | 41 | Your Cycle.js component function can expect sources to have `DOM` and `props`: 42 | 43 | ```typescript 44 | // TypeScript signature: 45 | type Sources = { 46 | DOM: DOMSource, 47 | props: Stream 48 | } 49 | ``` 50 | 51 | Your component's sinks should have `DOM` and any other sink will be converted to DOM events on the custom element: 52 | 53 | ```typescript 54 | // TypeScript signature: 55 | type Sinks = { 56 | DOM: Stream, 57 | bark: Stream, 58 | // `bark` sink stream will be converted to DOM Events emitted on the resulting custom element 59 | } 60 | ``` 61 | 62 | Write your function `MyButton: (sources: Sources) => Sinks` like you would do with any typical Cycle.js app. `sources.props` is a Stream of objects that contain attributes given to the custom element. 63 | 64 | Then convert it to a custom element class: 65 | 66 | ```js 67 | import customElementify from 'cycle-custom-elementify'; 68 | 69 | const customElementClass = customElementify(MyButton); 70 | ``` 71 | 72 | Then, register your custom element on the DOM with a tagName of your choice: 73 | 74 | ```js 75 | document.registerElement('my-button', customElementClass); 76 | ``` 77 | 78 | If you want to use this `my-button` inside another Cycle.js app, be careful to wait for the `WebComponentsReady` event first: 79 | 80 | ```js 81 | window.addEventListener('WebComponentsReady', () => { 82 | document.registerElement('my-button', { prototype: customElementify(MyButton) }); 83 | Cycle.run(main, { 84 | DOM: makeDOMDriver('#app-container') 85 | }); 86 | }); 87 | ``` 88 | 89 | If your parent Cycle.js app passes attributes to the custom element, then they will be available as `sources.props` in the child Cycle.js app (inside the custom element): 90 | 91 | ```js 92 | function main(sources) { 93 | // ... 94 | 95 | const vnode$ = xs.of( 96 | div([ 97 | h('my-button', {attrs: {color: 'red'}}) 98 | ]) 99 | ); 100 | 101 | // ... 102 | } 103 | ``` 104 | 105 | ```js 106 | function MyButton(sources) { 107 | const color$ = sources.props.map(p => p.color); 108 | 109 | // ... 110 | } 111 | ``` 112 | 113 | ## Known issues 114 | 115 | - This is an experimental library :) 116 | - We're following the Custom Elements V0 spec, not yet V1 117 | - The custom elements generated by this helper do not support children yet, only attributes 118 | - Using this library might confuse the Cycle.js DevTool 119 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Cycle.js example - JSX to show seconds elapsed 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "author": "Andre Staltz", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@cycle/dom": "12.2.x", 9 | "@cycle/xstream-run": "3.1.x", 10 | "cycle-custom-elementify": "^1.0.0", 11 | "webcomponents.js": "^0.7.22", 12 | "xstream": "6.2.x" 13 | }, 14 | "devDependencies": { 15 | "babel-preset-es2015": "^6.3.13", 16 | "babel-register": "^6.4.3", 17 | "babelify": "^7.2.0", 18 | "browserify": "11.0.1", 19 | "mkdirp": "0.5.x", 20 | "typescript": "1.8.7", 21 | "typings": "^1.0.4" 22 | }, 23 | "scripts": { 24 | "prebrowserify": "mkdirp dist && typings install && tsc", 25 | "browserify": "browserify lib/index.js -t babelify --outfile dist/main.js", 26 | "start": "npm install && npm run browserify && echo 'OPEN index.html IN YOUR BROWSER'" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /example/src/Item.ts: -------------------------------------------------------------------------------- 1 | import xs, {Stream} from 'xstream'; 2 | import {button, div, input, VNode} from '@cycle/dom'; 3 | import {DOMSource} from '@cycle/dom/xstream-typings'; 4 | 5 | export interface Action { 6 | type: 'CHANGE_COLOR' | 'CHANGE_WIDTH' | 'REMOVE'; 7 | payload?: any; 8 | } 9 | 10 | export interface Props { 11 | color: string; 12 | width: number; 13 | } 14 | 15 | type State = Props; 16 | 17 | function intent(domSource: DOMSource): Stream { 18 | return xs.merge( 19 | domSource.select('.color-field').events('input') 20 | .map(ev => ({ 21 | type: 'CHANGE_COLOR', 22 | payload: (ev.target as HTMLInputElement).value 23 | } as Action)), 24 | 25 | domSource.select('.width-slider').events('input') 26 | .map(ev => ({ 27 | type: 'CHANGE_WIDTH', 28 | payload: parseInt((ev.target as HTMLInputElement).value) 29 | } as Action)), 30 | 31 | domSource.select('.remove-btn').events('click') 32 | .mapTo({type: 'REMOVE'} as Action) 33 | ); 34 | } 35 | 36 | function model(props$: Stream, action$: Stream): Stream { 37 | const usePropsReducer$ = props$ 38 | .map(props => function usePropsReducer(oldState: State) { 39 | return props; 40 | }); 41 | 42 | const changeWidthReducer$ = action$ 43 | .filter(a => a.type === 'CHANGE_WIDTH') 44 | .map(action => function changeWidthReducer(oldState: State): State { 45 | return {color: oldState.color, width: action.payload}; 46 | }); 47 | 48 | const changeColorReducer$ = action$ 49 | .filter(a => a.type === 'CHANGE_COLOR') 50 | .map(action => function changeColorReducer(oldState: State): State { 51 | return {color: action.payload, width: oldState.width}; 52 | }); 53 | 54 | return xs.merge(usePropsReducer$, changeWidthReducer$, changeColorReducer$) 55 | .fold((state, reducer) => reducer(state), {color: '#888', width: 200}); 56 | } 57 | 58 | function view(state$: Stream) { 59 | return state$.map(({color, width}) => { 60 | const style = { 61 | border: '1px solid #000', 62 | background: 'none repeat scroll 0% 0% ' + color, 63 | width: width + 'px', 64 | height: '70px', 65 | display: 'block', 66 | padding: '20px', 67 | margin: '10px 0px' 68 | }; 69 | return div('.item', {style}, [ 70 | input('.color-field', { 71 | attrs: {type: 'text', value: color} 72 | }), 73 | div('.slider-container', [ 74 | input('.width-slider', { 75 | attrs: {type: 'range', min: '200', max: '1000', value: width} 76 | }) 77 | ]), 78 | div('.width-content', String(width)), 79 | button('.remove-btn', 'Remove') 80 | ]); 81 | }); 82 | } 83 | 84 | export interface Sources { 85 | DOM: DOMSource; 86 | props: Stream; 87 | } 88 | 89 | export interface Sinks { 90 | DOM: Stream; 91 | remove: Stream; 92 | } 93 | 94 | function Item(sources: Sources): Sinks { 95 | const action$ = intent(sources.DOM); 96 | const state$ = model(sources.props, action$); 97 | const vtree$ = view(state$); 98 | 99 | const remove$ = state$ 100 | .map(state => action$.filter(action => action.type === 'REMOVE')) 101 | .flatten(); 102 | 103 | return { 104 | DOM: vtree$, 105 | remove: remove$, 106 | }; 107 | } 108 | 109 | export default Item; 110 | -------------------------------------------------------------------------------- /example/src/List.ts: -------------------------------------------------------------------------------- 1 | import xs, {Stream} from 'xstream'; 2 | import {button, VNode, h, div} from '@cycle/dom'; 3 | import {DOMSource} from '@cycle/dom/xstream-typings'; 4 | 5 | export interface Sources { 6 | DOM: DOMSource; 7 | } 8 | 9 | export interface Sinks { 10 | DOM: Stream; 11 | } 12 | 13 | export interface ItemData { 14 | id: number; 15 | color: string; 16 | width: number; 17 | } 18 | 19 | export interface Action { 20 | type: 'ADD_ITEM' | 'REMOVE_ITEM'; 21 | payload?: any; 22 | } 23 | 24 | function intent(domSource: DOMSource): Stream { 25 | return xs.merge( 26 | domSource.select('.add-one-btn').events('click') 27 | .mapTo({type: 'ADD_ITEM', payload: 1} as Action), 28 | 29 | domSource.select('.add-many-btn').events('click') 30 | .mapTo({type: 'ADD_ITEM', payload: 1000} as Action), 31 | 32 | domSource.select('.item').events('remove') 33 | .map((ev: CustomEvent) => 34 | ({ type: 'REMOVE_ITEM', payload: (ev.target as HTMLElement).id } as Action) 35 | ) 36 | ); 37 | } 38 | 39 | let mutableId = 0; 40 | 41 | function model(action$: Stream): Stream> { 42 | function createRandomItemProps(): ItemData { 43 | let hexColor = Math.floor(Math.random() * 16777215).toString(16); 44 | while (hexColor.length < 6) { 45 | hexColor = '0' + hexColor; 46 | } 47 | hexColor = '#' + hexColor; 48 | const randomWidth = Math.floor(Math.random() * 800 + 200); 49 | return {color: hexColor, width: randomWidth, id: mutableId++}; 50 | } 51 | 52 | const addItemReducer$ = action$ 53 | .filter(a => a.type === 'ADD_ITEM') 54 | .map(action => { 55 | const amount = action.payload; 56 | let newItems: Array = []; 57 | for (let i = 0; i < amount; i++) { 58 | newItems.push(createRandomItemProps()); 59 | } 60 | return function addItemReducer(listItems: Array): Array { 61 | return listItems.concat(newItems); 62 | }; 63 | }); 64 | 65 | const removeItemReducer$ = action$ 66 | .filter(a => a.type === 'REMOVE_ITEM') 67 | .map(action => function removeItemReducer(listItems: Array): Array { 68 | return listItems.filter(item => `item-${item.id}` !== action.payload); 69 | }); 70 | 71 | const initialState: Array = [{ color: 'red', width: 300, id: mutableId++ }]; 72 | 73 | return xs.merge(addItemReducer$, removeItemReducer$) 74 | .fold((listItems, reducer) => reducer(listItems), initialState); 75 | } 76 | 77 | function view(items$: Stream>) { 78 | return items$.map(items => 79 | div('.addButtons', [ 80 | button('.add-one-btn', 'Add New Item'), 81 | button('.add-many-btn', 'Add Many Items'), 82 | ...(items.map(x => 83 | h(`many-item#item-${x.id}.item`, { 84 | key: `${x.id}`, 85 | attrs: { color: x.color, width: x.width }, 86 | })) 87 | ) 88 | ]) 89 | ); 90 | } 91 | 92 | export default function List(sources: Sources): Sinks { 93 | const action$ = intent(sources.DOM); 94 | const items$ = model(action$); 95 | const vdom$ = view(items$); 96 | 97 | return { 98 | DOM: vdom$ 99 | } 100 | } -------------------------------------------------------------------------------- /example/src/index.ts: -------------------------------------------------------------------------------- 1 | import xs, {Stream} from 'xstream'; 2 | import {run} from '@cycle/xstream-run'; 3 | import {h, VNode, makeDOMDriver} from '@cycle/dom'; 4 | import Item from './Item'; 5 | import List from './List'; 6 | import {DOMSource} from '@cycle/dom/xstream-typings'; 7 | import customElementify from 'cycle-custom-elementify'; 8 | 9 | interface Sources { 10 | DOM: DOMSource; 11 | } 12 | 13 | interface Sinks { 14 | DOM: Stream; 15 | } 16 | 17 | function main(sources: Sources): Sinks { 18 | return List(sources); 19 | } 20 | 21 | window.addEventListener('WebComponentsReady', () => { 22 | (document as any).registerElement('many-item', { prototype: customElementify(Item as any) }); 23 | run(main, { 24 | DOM: makeDOMDriver('#main-container') 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "removeComments": false, 4 | "preserveConstEnums": true, 5 | "sourceMap": true, 6 | "declaration": true, 7 | "noImplicitAny": true, 8 | "suppressImplicitAnyIndexErrors": true, 9 | "module": "commonjs", 10 | "target": "ES6", 11 | "outDir": "lib/" 12 | }, 13 | "formatCodeOptions": { 14 | "indentSize": 2, 15 | "tabSize": 2 16 | }, 17 | "files": [ 18 | "typings/index.d.ts", 19 | "src/index.ts" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /example/typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "globalDependencies": { 3 | "require": "github:DefinitelyTyped/DefinitelyTyped/requirejs/require.d.ts#56295f5058cac7ae458540423c50ac2dcf9fc711" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cycle-custom-elementify", 3 | "version": "1.0.2", 4 | "author": "Andre Staltz", 5 | "license": "MIT", 6 | "main": "lib/index.js", 7 | "typings": "lib/index.d.ts", 8 | "publishConfig": { 9 | "access": "public" 10 | }, 11 | "browserify-shim": { 12 | "xstream": "global:xstream" 13 | }, 14 | "dependencies": { 15 | "@cycle/dom": "12.2.x", 16 | "@cycle/xstream-run": "3.1.x", 17 | "xstream": "6.2.x" 18 | }, 19 | "devDependencies": { 20 | "babel-preset-es2015": "^6.3.13", 21 | "babel-register": "^6.4.3", 22 | "babelify": "^7.2.0", 23 | "browserify": "11.0.1", 24 | "browserify-shim": "^3.8.12", 25 | "mkdirp": "0.5.x", 26 | "typescript": "1.8.7", 27 | "uglify-js": "^2.7.3" 28 | }, 29 | "scripts": { 30 | "browserify": "browserify lib/index.js -t babelify -t browserify-shim --standalone customElementify --exclude xstream --outfile dist/cycle-custom-elementify.js", 31 | "uglify": "uglifyjs dist/cycle-custom-elementify.js -o dist/cycle-custom-elementify.min.js", 32 | "prelib": "rm -rf lib/ && mkdir -p lib", 33 | "lib": "tsc", 34 | "predist": "rm -rf dist/ && mkdir -p dist/", 35 | "dist": "npm run browserify && npm run uglify", 36 | "release-patch": "npm --no-git-tag-version version patch", 37 | "release-minor": "npm --no-git-tag-version version minor", 38 | "release-major": "npm --no-git-tag-version version major" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import xs, {Stream, Listener} from 'xstream'; 2 | import {VNode, h, makeDOMDriver} from '@cycle/dom'; 3 | import Cycle from '@cycle/xstream-run'; 4 | import {DOMSource} from '@cycle/dom/xstream-typings'; 5 | 6 | export interface RequiredSources { 7 | DOM: DOMSource; 8 | props: Stream; 9 | } 10 | 11 | export interface RequiredSinks { 12 | DOM: Stream; 13 | } 14 | 15 | export type Component = (sources: RequiredSources) => RequiredSinks; 16 | 17 | type CycleExec = { 18 | sources: RequiredSources; 19 | sinks: RequiredSinks; 20 | run: () => () => {}; 21 | } 22 | 23 | export interface CustomElementV0 { 24 | createdCallback(): void; 25 | attachedCallback(): void; 26 | detachedCallback(): void; 27 | attributeChangedCallback(attrName: string): void; 28 | } 29 | 30 | export interface CyclejsCustomElement extends CustomElementV0 { 31 | _cyclejsProps$: Stream; 32 | _cyclejsProps: Object; 33 | _cyclejsDispose(): void; 34 | } 35 | 36 | export interface SinkForCustomElement extends Stream { 37 | _customElementListener: Listener; 38 | } 39 | 40 | function createDispatcherForSink(eventName: string, element: Element): Listener { 41 | return { 42 | next: (detail: any) => { 43 | const event = document.createEvent('Event'); 44 | event.initEvent(eventName, true, true); 45 | event['detail'] = detail; 46 | element.dispatchEvent(event); 47 | }, 48 | error: () => { }, 49 | complete: () => { }, 50 | } 51 | } 52 | 53 | function createDispatcherForAllSinks(sinks: Object): Listener { 54 | return { 55 | next: (element: Element) => { 56 | for (let key in sinks) { 57 | if (sinks.hasOwnProperty(key) && key !== 'DOM') { 58 | const sink = sinks[key] as SinkForCustomElement; 59 | sink.removeListener(sink._customElementListener); 60 | sink._customElementListener = createDispatcherForSink(key, element); 61 | sink.addListener(sink._customElementListener); 62 | } 63 | } 64 | }, 65 | error: () => { }, 66 | complete: () => { }, 67 | }; 68 | } 69 | 70 | function makePropsObject(element: HTMLElement): Object { 71 | const result = {}; 72 | const attributes = element.attributes; 73 | for (let i = 0, N = attributes.length; i < N; i++) { 74 | const attribute = attributes[i]; 75 | result[attribute.name] = attribute.value; 76 | } 77 | return result; 78 | } 79 | 80 | export default function customElementify(component: Component): typeof HTMLElement { 81 | var CEPrototype = Object.create(HTMLElement.prototype) as any as (CyclejsCustomElement & typeof HTMLElement); 82 | 83 | CEPrototype.attachedCallback = function attachedCallback() { 84 | const self: CyclejsCustomElement & HTMLElement = this; 85 | self._cyclejsProps$ = xs.create(); 86 | const {sources, sinks, run} = Cycle(component, { 87 | DOM: makeDOMDriver(self), 88 | props: () => self._cyclejsProps$ 89 | }) as CycleExec; 90 | sources.DOM.elements().addListener(createDispatcherForAllSinks(sinks)); 91 | self._cyclejsDispose = run(); 92 | self._cyclejsProps = makePropsObject(self); 93 | self._cyclejsProps$.shamefullySendNext(self._cyclejsProps); 94 | }; 95 | 96 | CEPrototype.detachedCallback = function detachedCallback() { 97 | (this as CyclejsCustomElement)._cyclejsDispose(); 98 | }; 99 | 100 | CEPrototype.attributeChangedCallback = function attributeChangedCallback(attrName: string) { 101 | const self: CyclejsCustomElement & HTMLElement = this; 102 | if (!self._cyclejsProps) return; 103 | self._cyclejsProps[attrName] = self.attributes.getNamedItem(attrName).value; 104 | self._cyclejsProps$.shamefullySendNext(self._cyclejsProps); 105 | }; 106 | 107 | return CEPrototype; 108 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "removeComments": false, 4 | "preserveConstEnums": true, 5 | "sourceMap": true, 6 | "declaration": true, 7 | "noImplicitAny": true, 8 | "suppressImplicitAnyIndexErrors": true, 9 | "module": "commonjs", 10 | "target": "ES6", 11 | "outDir": "lib/" 12 | }, 13 | "formatCodeOptions": { 14 | "indentSize": 2, 15 | "tabSize": 2 16 | }, 17 | "files": [ 18 | "src/index.ts" 19 | ] 20 | } 21 | --------------------------------------------------------------------------------