diff --git a/packages/csx-custom-elements/src/.babelrc b/packages/csx-custom-elements/src/.babelrc index 124e2c5..c96a022 100644 --- a/packages/csx-custom-elements/src/.babelrc +++ b/packages/csx-custom-elements/src/.babelrc @@ -2,7 +2,7 @@ "presets": [ ], "plugins": [ - [ "@babel/plugin-proposal-decorators", { "legacy": true }], + [ "@babel/plugin-proposal-decorators" , { "decoratorsBeforeExport": true }], [ "@babel/plugin-proposal-class-properties", { "loose": true } ], [ "@babel/plugin-proposal-private-methods", {"loose": true } ], [ "@babel/plugin-proposal-optional-chaining" ], 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 1ca307e..e112c87 100644 --- a/packages/csx-custom-elements/src/custom-element/custom-element.js +++ b/packages/csx-custom-elements/src/custom-element/custom-element.js @@ -1,5 +1,43 @@ import {render} from "../vdom"; + +/** + * The decorators proposal has changed since @babel implemented it. This code will need to change at some point... + */ +export function State() { + return function decorator(target){ + let key = target.key; + let descriptor = target.descriptor; + let value = undefined; + let {get: oldGet, set: oldSet} = descriptor; + + // Add a getter/setter or replace if they're already there with something that intercepts it (this gets a whole lot easyer in the new proposal if i'm not mistaken) + descriptor['get'] = oldGet || (function(){ + return value + }); + descriptor['set'] = function(newValue){ + if(newValue!==descriptor.get.call(this)){ + value = newValue; + this.markDirty && this.markDirty(); + } + if(oldSet) return oldSet.call(this, newValue); + }; + + // CAUTION: this is dangerous. We need intend to conver regular fields to get/set methods here. + delete descriptor.writable; + target.kind = 'method'; // update to get and set if need be.. + + // CAUTION: this is again dangerous, the initialize function should be called right before the constructor, but after it was fully defined. + if(target.initializer){ + value = target.initializer(target); + delete target.initializer; + } + + return target; + } +} + + /** * 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{})' @@ -8,15 +46,29 @@ import {render} from "../vdom"; */ export class CustomElement extends HTMLElement { connectedCallback() { - if(this.render){ - let newVNode = this.render(); - render(newVNode, { - host: this - }); - } + this.update(); } disconnectedCallback(){ } + + #markedDirty; + #renderedVNode; + update(){ + if (this.render) { + let newVNode = this.render(); + render(newVNode, { + host: this, + oldVNode: this.#renderedVNode + }); + this.#renderedVNode = newVNode; + } + this.#markedDirty=false; + } + markDirty() { + if (!this.#markedDirty) { + this.#markedDirty = requestAnimationFrame(() => this.update()); + } + } } diff --git a/packages/csx-custom-elements/src/custom-element/define-element.js b/packages/csx-custom-elements/src/custom-element/define-element.js index b4932f6..03d5b9d 100644 --- a/packages/csx-custom-elements/src/custom-element/define-element.js +++ b/packages/csx-custom-elements/src/custom-element/define-element.js @@ -1,18 +1,22 @@ - /** * The decorators proposal has changed since @babel implemented it. This code will need to change at some point... */ export function defineElement(tagName, options) { - return function decorator(target){ - // Register the tagName as a custom-element with the browser - window.customElements.define(tagName, target, options); - - // Define the chosen tagName on the class itself so our vdom.render-function knows what DOM-Element to create - Object.defineProperty(target, 'tagName', { - value: tagName, - writable: false, - enumerable: false, - configurable: false - }); - } -} \ No newline at end of file + return function decorator(target) { + // Queue defining element in a finisher, because apparantly thats how the non-legacy decorator proposal works (again, new proposal will be different...) + target.finisher = (finishedTarget)=>{ + // Register the tagName as a custom-element with the browser + window.customElements.define(tagName, finishedTarget, options); + + // Define the chosen tagName on the class itself so our vdom.render-function knows what DOM-Element to create + Object.defineProperty(finishedTarget, 'tagName', { + value: tagName, + writable: false, + enumerable: false, + configurable: false + }); + return finishedTarget; + }; + return target; + }; +} diff --git a/packages/csx-custom-elements/src/vdom/render.js b/packages/csx-custom-elements/src/vdom/render.js index 3c3eaa3..f9903af 100644 --- a/packages/csx-custom-elements/src/vdom/render.js +++ b/packages/csx-custom-elements/src/vdom/render.js @@ -6,8 +6,6 @@ import { VNODEPROP_IGNORE, } from "./constants"; -// This is copied from the blog sample right now. it should process jsx but it aint what it needs to be - /** * @typedef {Object} RenderOptions * @category VDOM @@ -25,95 +23,130 @@ export function render(vnode, opts = {}) { // 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} - */ - host - } = opts; + // TODO Proper updating of a previous rendered vnode - if(VNODE_EXCLUDE[vnode]) return undefined; + let queue = [{vnode, opts, parent: null}]; + let newRoot = undefined; + while(queue.length>0){ + let {vnode, opts, parent} = queue.splice(0,1)[0]; + let { + /** + * @type {Element} + */ + host, + /** + * @type {VNode} + */ + oldVNode + } = opts; - 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 ]; + if(VNODE_EXCLUDE[vnode]) continue;// Skip + + if(!host){ + if(!['object', 'function', 'symbol'].includes(typeof(vnode))) { + host = document.createTextNode(vnode); + }else if(vnode?.type === ShadowDOM) { + if(!parent.node) { + throw new Error("Can't identify to what element ShadowDOM is to be attached"); + } + host = parent.node.attachShadow({ mode: 'open' }); + }else if(vnode?.type === Host){ + if(!parent.node) { + throw new Error("Can't identify to what element ShadowDOM is to be attached"); + } + host = parent.node; + }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); } } - 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('-')){ + + // Props + if (vnode?.props) { + let props = vnode.props; + + if (props.style && typeof (props.style) === 'object') { + for (let styleKey in props.style) { + host.style[ styleKey ] = props.style[ styleKey ]; + } + } + for (let key in props) { + let val = props[key]; + if(VNODEPROP_IGNORE[key]){ + // NO-OP + }else if(VNODEPROP_DIRECT[key]){ 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"); - } - - }else { - if (val === false || val===null || val==='') { - host.removeAttribute(key); - } else if (val === true) { - host.setAttribute(key, ""); - } else { - host.setAttribute(key, val); - } - } - } - } - } - - // 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); + 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"); + } + + }else { + if (val === false || val===null || val==='') { + host.removeAttribute(key); + } else if (val === true) { + host.setAttribute(key, ""); + } else { + host.setAttribute(key, val); + } + } } - } } + + // Children + if (vnode?.children) { + let childVQueue = vnode.children instanceof Array? vnode.children.slice() : [vnode.children]; + let childQueue = []; + while(childVQueue.length){ + let child = childVQueue.splice(0,1)[0]; + // TODO support chidl instances of Element + if(child instanceof Array){ + childVQueue.splice(0,0,...child); + }else{ + + childQueue.push({ + vnode: child, + opts: { + ...opts, + host: undefined, + }, + parent: { // TODO specify a previous child or index here if need be... + node:host, + + // Really dirty way here to pass in what was probably the old element for this, and it must absolutely go + oldChild: oldVNode?host.childNodes[childQueue.length]:null + } + }); + } + } + queue.splice(0,0,...childQueue); + } + + if(parent){ + if(parent.oldChild){ + parent.node.replaceChild(host, parent.oldChild); + }else{ + parent.node.appendChild(host); + } + }else{ + newRoot = host; + } } - return host; + return newRoot; } \ No newline at end of file diff --git a/test/.babelrc b/test/.babelrc index c13dbd2..2331d3f 100644 --- a/test/.babelrc +++ b/test/.babelrc @@ -7,7 +7,7 @@ }] ], "plugins": [ - [ "@babel/plugin-proposal-decorators", { "legacy": true }], + [ "@babel/plugin-proposal-decorators", { "decoratorsBeforeExport": true }], [ "@babel/plugin-proposal-class-properties", { "loose": true } ], [ "@babel/plugin-proposal-private-methods", {"loose": true } ], [ "@babel/plugin-proposal-optional-chaining" ], diff --git a/test/todos-mvc/components/my-todo.jsx b/test/todos-mvc/components/my-todo.jsx index 3501d9a..b793b7c 100644 --- a/test/todos-mvc/components/my-todo.jsx +++ b/test/todos-mvc/components/my-todo.jsx @@ -1,4 +1,4 @@ -import {defineElement, render, CustomElement, Host} from "../../../packages/csx-custom-elements"; +import {defineElement, render, CustomElement, Host, State} from "../../../packages/csx-custom-elements"; import style from './my-todo.scss'; import {TodoInput} from './todo-input'; @@ -7,7 +7,8 @@ import {TodoItem} from './todo-item'; @defineElement('my-todo') export class MyTodo extends CustomElement{ uid = 1; - todos = [ + // @State Won't work; + @State() todos = [ {id: this.uid++, text: "my initial todo", checked: false }, {id: this.uid++, text: "Learn about Web Components", checked: false }, ]; @@ -36,7 +37,10 @@ export class MyTodo extends CustomElement{ } handleSubmit = ({ detail: text }) => { - this.todos = [...this.todos, { id: this.uid++, text, checked: false }]; + if(text) { + console.log("Submit rcvd: " + text); + this.todos = [...this.todos, { id: this.uid++, text, checked: false }]; + } }; handleCheck = ({detail: checked}, id) => { let indexOf = this.todos.findIndex(t=>t.id===id); diff --git a/test/todos-mvc/components/todo-item.jsx b/test/todos-mvc/components/todo-item.jsx index 8ee8de3..e886968 100644 --- a/test/todos-mvc/components/todo-item.jsx +++ b/test/todos-mvc/components/todo-item.jsx @@ -27,7 +27,7 @@ export class TodoItem extends CustomElement{ handleChange = ()=>{ this.dispatchEvent(new CustomEvent('check', { - detail: (this.checked=!this.checked) + detail: (this.checked=!this.checked), })); }; handleClick = ()=>{