From 5169c5018d0d371c9561dc7fb1003449acea2b95 Mon Sep 17 00:00:00 2001 From: Miel Truyen Date: Wed, 23 Oct 2019 01:13:27 +0200 Subject: [PATCH] Initial rendering of a Todos-MVC app. (events are called but don't trigger a re-rendering yet, renderin-function does not yet support updating DOM yet) --- .../src/custom-element/custom-element.js | 16 ++- .../csx-custom-elements/src/vdom/constants.js | 5 +- .../csx-custom-elements/src/vdom/index.js | 3 +- .../csx-custom-elements/src/vdom/render.js | 108 +++++++++++------- rollup.config.js | 8 +- test/basic/{page.js => page.jsx} | 2 +- test/todos-mvc/components/my-todo.jsx | 50 ++++++++ test/todos-mvc/components/my-todo.scss | 24 ++++ test/todos-mvc/components/todo-input.jsx | 35 ++++++ test/todos-mvc/components/todo-input.scss | 29 +++++ test/todos-mvc/components/todo-item.jsx | 37 ++++++ test/todos-mvc/components/todo-item.scss | 88 ++++++++++++++ test/todos-mvc/index.jsx | 3 +- 13 files changed, 361 insertions(+), 47 deletions(-) rename test/basic/{page.js => page.jsx} (91%) create mode 100644 test/todos-mvc/components/my-todo.jsx create mode 100644 test/todos-mvc/components/my-todo.scss create mode 100644 test/todos-mvc/components/todo-input.jsx create mode 100644 test/todos-mvc/components/todo-input.scss create mode 100644 test/todos-mvc/components/todo-item.jsx create mode 100644 test/todos-mvc/components/todo-item.scss diff --git a/packages/csx-custom-elements/src/custom-element/custom-element.js b/packages/csx-custom-elements/src/custom-element/custom-element.js index 68f89ad..1ca307e 100644 --- a/packages/csx-custom-elements/src/custom-element/custom-element.js +++ b/packages/csx-custom-elements/src/custom-element/custom-element.js @@ -1,8 +1,22 @@ +import {render} from "../vdom"; + /** * This CustomElement class is to avoid having to do an ugly workaround in every custom-element: * Which would be replacing 'HTMLElement' with '(class extends HTMLElement{})' * * Also, it is a good starting point for implementing render() functionality, listening to props, state changes, events and whatnot (use decorators) */ -export class CustomElement extends HTMLElement {} +export class CustomElement extends HTMLElement { + connectedCallback() { + if(this.render){ + let newVNode = this.render(); + render(newVNode, { + host: this + }); + } + } + disconnectedCallback(){ + + } +} diff --git a/packages/csx-custom-elements/src/vdom/constants.js b/packages/csx-custom-elements/src/vdom/constants.js index c99fb5b..0ff4d60 100644 --- a/packages/csx-custom-elements/src/vdom/constants.js +++ b/packages/csx-custom-elements/src/vdom/constants.js @@ -15,4 +15,7 @@ export const VNODEPROP_EXCLUDE_DIRECT = { export const VNODEPROP_IGNORE = { ['key']: true, -}; \ No newline at end of file +}; + +export const Host = Symbol('host'); +export const ShadowDOM = Symbol('shadow-dom'); \ No newline at end of file diff --git a/packages/csx-custom-elements/src/vdom/index.js b/packages/csx-custom-elements/src/vdom/index.js index 919e1c7..22ef628 100644 --- a/packages/csx-custom-elements/src/vdom/index.js +++ b/packages/csx-custom-elements/src/vdom/index.js @@ -1,2 +1,3 @@ export * from "./vnode"; -export * from "./render"; \ No newline at end of file +export * from "./render"; +export {Host, ShadowDOM} from "./constants"; \ No newline at end of file diff --git a/packages/csx-custom-elements/src/vdom/render.js b/packages/csx-custom-elements/src/vdom/render.js index aad01cf..3c3eaa3 100644 --- a/packages/csx-custom-elements/src/vdom/render.js +++ b/packages/csx-custom-elements/src/vdom/render.js @@ -1,5 +1,6 @@ import './vnode'; import { + Host, ShadowDOM, VNODE_EXCLUDE, VNODEPROP_DIRECT, VNODEPROP_EXCLUDE_DIRECT, VNODEPROP_IGNORE, @@ -21,7 +22,11 @@ import { * @return {Element} */ export function render(vnode, opts = {}) { - // Replace how this works into a queue instead of a recursive call, also consider changing JSX to support (changed)="..." notation + // TODO figure out how to handle events (its not that easy to create (click)={this.onClick} or something, that is not supported by the @babel/parser and we'd have to fork it.. + // ---> We've got a basic onClick (react-style) system set up now + // TODO innerHTML, innerText and other tags/props that are trickyer then just mapping value to attribute + // TODO: Replace how this works into a queue instead of a recursive call, also consider changing JSX to support (changed)="..." notation + // TODO ref-prop (should it only return once all child els are created and appended to the child?!) let { /** * @type {Element} @@ -30,61 +35,84 @@ export function render(vnode, opts = {}) { } = opts; if(VNODE_EXCLUDE[vnode]) return undefined; - console.log(vnode); - if(vnode instanceof Object){ - // Type - let tagName = vnode.type instanceof Object? vnode.type.tagName : vnode.type; - if(!host) host = document.createElement(tagName); - - // Props - if (vnode.props) { - if (vnode.props.style && typeof (vnode.props.style) === 'object') { - for (let styleKey in vnode.props.style) { - host.style[ styleKey ] = vnode.props.style[ styleKey ]; - } + if(!host){ + if(!['object', 'function', 'symbol'].includes(typeof(vnode))){ + host = document.createTextNode(vnode); + }else if(typeof(vnode?.type) === 'string'){ + host = document.createElement(vnode.type); + }else if(vnode?.type?.tagName){ + host = document.createElement(vnode.type.tagName); + }else{ + throw new Error("Unrecognized vnode type", vnode); + } + } + + // Props + if (vnode?.props) { + if (vnode.props.style && typeof (vnode.props.style) === 'object') { + for (let styleKey in vnode.props.style) { + host.style[ styleKey ] = vnode.props.style[ styleKey ]; } - for (let key in vnode.props) { - let val = vnode.props[key]; - if(VNODEPROP_IGNORE[key]){ - // NO-OP - }else if(VNODEPROP_DIRECT[key]){ + } + for (let key in vnode.props) { + let val = vnode.props[key]; + if(VNODEPROP_IGNORE[key]){ + // NO-OP + }else if(VNODEPROP_DIRECT[key]){ + host[key] = val; + }else{ + if(!VNODEPROP_EXCLUDE_DIRECT[key] && !key.indexOf('-')){ host[key] = val; - }else{ - if(!VNODEPROP_EXCLUDE_DIRECT[key] && !key.indexOf('-')){ - host[key] = val; + } + if(key.slice(0,2)==='on' && key[2]>='A' && key[2]<='Z'){ + if(val instanceof Function){ + host.addEventListener( + // Convert camelCase to dash-case + key[2].toLowerCase()+key.slice(3).replace(/[A-Z]/g, function(c){return('-'+c.toLowerCase())}), + val + ); + }else{ + new Error("Unsupported event-handler"); } - if (val === false) { + + }else { + if (val === false || val===null || val==='') { host.removeAttribute(key); } else if (val === true) { host.setAttribute(key, ""); - } else{ + } else { host.setAttribute(key, val); } } } } + } - // Children - if (vnode.children) { - let children = vnode.children instanceof Array? vnode.children : [vnode.children]; - - for(let child of children){ - let el = child instanceof Element? child : render(child, { - ...opts, - host: undefined - }); - if(el!==undefined){ - host.appendChild(el); + // Children + if (vnode?.children) { + let queue = vnode.children instanceof Array? vnode.children.slice() : [vnode.children]; + while(queue.length){ + let child = queue.splice(0,1)[0]; + if(child instanceof Array){ + queue.splice(0,0,...child); + }else{ + if(child?.type === ShadowDOM){ + let shadow = host.attachShadow({mode: 'open'}); + render({children: child.children}, { + ...opts, + host: shadow + }); + }else{ + let el = child instanceof Element? child : render(child, { + ...opts, + host: undefined + }); + if(el!==undefined) host.appendChild(el); } + } } - - // TODO figure out how to handle events (its not that easy to create (click)={this.onClick} or something, that is not supporter by the @babel/parser and we'd have to fork it.. - // TODO ref-prop (should it only return once all child els are created and appended to the child?!) - // TODO innerHTML, innerText and other tags/props that are trickyer then just mapping value to attribute - }else{ - if(!host) host = document.createTextNode(vnode); } return host; diff --git a/rollup.config.js b/rollup.config.js index 801eec3..b85f7fb 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -21,7 +21,9 @@ export default [ plugins: [ sass(), babel(), // babel - resolve(), // node_modules + resolve({ + extensions: [ '.mjs', '.js', '.jsx', '.json' ], + }), // node_modules commonjs(), // CJS-modules production && terser(), // minify, but only in production copy({ @@ -43,7 +45,9 @@ export default [ plugins: [ sass(), babel(), // babel - resolve(), // node_modules + resolve({ + extensions: [ '.mjs', '.js', '.jsx', '.json' ], + }), // node_modules commonjs(), // CJS-modules production && terser(), // minify, but only in production copy({ diff --git a/test/basic/page.js b/test/basic/page.jsx similarity index 91% rename from test/basic/page.js rename to test/basic/page.jsx index da36748..446253c 100644 --- a/test/basic/page.js +++ b/test/basic/page.jsx @@ -1,4 +1,4 @@ -import {defineElement, render, CustomElement} from "../../packages/csx-custom-elements/lib"; +import {defineElement, render, CustomElement} from "../../packages/csx-custom-elements"; @defineElement('example-page') export class ExamplePage extends CustomElement{ diff --git a/test/todos-mvc/components/my-todo.jsx b/test/todos-mvc/components/my-todo.jsx new file mode 100644 index 0000000..3501d9a --- /dev/null +++ b/test/todos-mvc/components/my-todo.jsx @@ -0,0 +1,50 @@ +import {defineElement, render, CustomElement, Host} from "../../../packages/csx-custom-elements"; + +import style from './my-todo.scss'; +import {TodoInput} from './todo-input'; +import {TodoItem} from './todo-item'; + +@defineElement('my-todo') +export class MyTodo extends CustomElement{ + uid = 1; + todos = [ + {id: this.uid++, text: "my initial todo", checked: false }, + {id: this.uid++, text: "Learn about Web Components", checked: false }, + ]; + + render(){ + return ( + + +

CSX Todo

+
+ +
    + {this.todos.map(item => + { item.text } + )} +
+
+
+ ); + } + + handleSubmit = ({ detail: text }) => { + this.todos = [...this.todos, { id: this.uid++, text, checked: false }]; + }; + handleCheck = ({detail: checked}, id) => { + let indexOf = this.todos.findIndex(t=>t.id===id); + let updated = {...this.todos[indexOf], checked}; + this.todos = [...this.todos.slice(0,indexOf), updated, ...this.todos.slice(indexOf+1)]; + }; + handleRemove = (e,id)=>{ + let indexOf = this.todos.findIndex(t=>t.id===id); + this.todos = [...this.todos.slice(0,indexOf), ...this.todos.slice(indexOf+1)]; + } +} \ No newline at end of file diff --git a/test/todos-mvc/components/my-todo.scss b/test/todos-mvc/components/my-todo.scss new file mode 100644 index 0000000..130a684 --- /dev/null +++ b/test/todos-mvc/components/my-todo.scss @@ -0,0 +1,24 @@ +:host { + display: block; +} + +h1 { + font-size: 60px; + font-weight: 100; + text-align: center; + color: rgba(175, 47, 47, 0.15); +} + +section { + background: #fff; + margin: 30px 0 40px 0; + position: relative; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1); +} + +#list-container { + margin: 0; + padding: 0; + list-style: none; + border-top: 1px solid #e6e6e6; +} \ No newline at end of file diff --git a/test/todos-mvc/components/todo-input.jsx b/test/todos-mvc/components/todo-input.jsx new file mode 100644 index 0000000..75182b5 --- /dev/null +++ b/test/todos-mvc/components/todo-input.jsx @@ -0,0 +1,35 @@ +import {defineElement, render, CustomElement, Host} from "../../../packages/csx-custom-elements"; +import style from './todo-input.scss'; + +@defineElement('todo-input') +export class TodoInput extends CustomElement{ + value = ""; + + render(){ + return ( + + +
+ +
+
+ ) + } + + handleSubmit = (e)=>{ + e.preventDefault(); + if (!this.value) return; + this.dispatchEvent(new CustomEvent('submit', { + detail: this.value + })); + this.state = ""; + }; + handleInput = ({target: {value}})=>{ + this.value = value; + }; +} \ No newline at end of file diff --git a/test/todos-mvc/components/todo-input.scss b/test/todos-mvc/components/todo-input.scss new file mode 100644 index 0000000..38a9c4e --- /dev/null +++ b/test/todos-mvc/components/todo-input.scss @@ -0,0 +1,29 @@ +:host { + display: block; +} + +form { + position: relative; + font-size: 24px; + border-bottom: 1px solid #ededed; +} + +input { + padding: 16px 16px 16px 60px; + border: none; + background: rgba(0, 0, 0, 0.003); + position: relative; + margin: 0; + width: 100%; + font-size: 24px; + font-family: inherit; + font-weight: inherit; + line-height: 1.4em; + border: 0; + outline: none; + color: inherit; + padding: 6px; + border: 1px solid #CCC; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + box-sizing: border-box; +} \ No newline at end of file diff --git a/test/todos-mvc/components/todo-item.jsx b/test/todos-mvc/components/todo-item.jsx new file mode 100644 index 0000000..8ee8de3 --- /dev/null +++ b/test/todos-mvc/components/todo-item.jsx @@ -0,0 +1,37 @@ +import {defineElement, render, CustomElement, Host, ShadowDOM} from "../../../packages/csx-custom-elements"; +import style from './todo-item.scss'; + +@defineElement('todo-item') +export class TodoItem extends CustomElement{ + checked = false;// TODO annotate as prop (attribute) + + render(){ + return ( + + + +
  • + + + +
  • +
    +
    + ); + } + + handleChange = ()=>{ + this.dispatchEvent(new CustomEvent('check', { + detail: (this.checked=!this.checked) + })); + }; + handleClick = ()=>{ + this.dispatchEvent(new CustomEvent('remove', { + })); + }; +} \ No newline at end of file diff --git a/test/todos-mvc/components/todo-item.scss b/test/todos-mvc/components/todo-item.scss new file mode 100644 index 0000000..63ba8b9 --- /dev/null +++ b/test/todos-mvc/components/todo-item.scss @@ -0,0 +1,88 @@ +:host { + display: block; +} + +li { + font-size: 24px; + display: block; + position: relative; + border-bottom: 1px solid #ededed; +} + +li input { + text-align: center; + width: 40px; + /* auto, since non-WebKit browsers doesn't support input styling */ + height: auto; + position: absolute; + top: 9px; + bottom: 0; + margin: auto 0; + border: none; + /* Mobile Safari */ + -webkit-appearance: none; + appearance: none; +} + +li input:after { + content: url('data:image/svg+xml;utf8,'); +} + +li input:checked:after { + content: url('data:image/svg+xml;utf8,'); +} + +li label { + white-space: pre; + word-break: break-word; + padding: 15px 60px 15px 15px; + margin-left: 45px; + display: block; + line-height: 1.2; + transition: color 0.4s; +} + +li.completed label { + color: #d9d9d9; + text-decoration: line-through; +} + +li button, +li input[type="checkbox"] { + outline: none; +} + +li button { + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 100%; + vertical-align: baseline; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + font-smoothing: antialiased; +} + +li button { + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 30px; + color: #cc9a9a; + margin-bottom: 11px; + transition: color 0.2s ease-out; +} + +li button:hover { + color: #af5b5e; +} \ No newline at end of file diff --git a/test/todos-mvc/index.jsx b/test/todos-mvc/index.jsx index ab0856b..acf420c 100644 --- a/test/todos-mvc/index.jsx +++ b/test/todos-mvc/index.jsx @@ -1,9 +1,10 @@ import {render} from "../../packages/csx-custom-elements"; import style from "./index.scss"; +import {MyTodo} from "./components/my-todo"; // Replace this with an example implementation of the Todos-MVC app // look for inspiration here: https://github.com/shprink/web-components-todo document.body.appendChild(render()); document.body.appendChild(render(
    -

    Todos MVC

    +
    ));