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)

This commit is contained in:
Miel Truyen 2019-10-23 01:13:27 +02:00
parent 31cfda50f5
commit 5169c5018d
13 changed files with 361 additions and 47 deletions

View File

@ -1,8 +1,22 @@
import {render} from "../vdom";
/** /**
* This CustomElement class is to avoid having to do an ugly workaround in every custom-element: * 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{})' * 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) * 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(){
}
}

View File

@ -16,3 +16,6 @@ export const VNODEPROP_EXCLUDE_DIRECT = {
export const VNODEPROP_IGNORE = { export const VNODEPROP_IGNORE = {
['key']: true, ['key']: true,
}; };
export const Host = Symbol('host');
export const ShadowDOM = Symbol('shadow-dom');

View File

@ -1,2 +1,3 @@
export * from "./vnode"; export * from "./vnode";
export * from "./render"; export * from "./render";
export {Host, ShadowDOM} from "./constants";

View File

@ -1,5 +1,6 @@
import './vnode'; import './vnode';
import { import {
Host, ShadowDOM,
VNODE_EXCLUDE, VNODE_EXCLUDE,
VNODEPROP_DIRECT, VNODEPROP_EXCLUDE_DIRECT, VNODEPROP_DIRECT, VNODEPROP_EXCLUDE_DIRECT,
VNODEPROP_IGNORE, VNODEPROP_IGNORE,
@ -21,7 +22,11 @@ import {
* @return {Element} * @return {Element}
*/ */
export function render(vnode, opts = {}) { 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 { let {
/** /**
* @type {Element} * @type {Element}
@ -30,15 +35,21 @@ export function render(vnode, opts = {}) {
} = opts; } = opts;
if(VNODE_EXCLUDE[vnode]) return undefined; if(VNODE_EXCLUDE[vnode]) return undefined;
console.log(vnode);
if(vnode instanceof Object){ if(!host){
// Type if(!['object', 'function', 'symbol'].includes(typeof(vnode))){
let tagName = vnode.type instanceof Object? vnode.type.tagName : vnode.type; host = document.createTextNode(vnode);
if(!host) host = document.createElement(tagName); }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 // Props
if (vnode.props) { if (vnode?.props) {
if (vnode.props.style && typeof (vnode.props.style) === 'object') { if (vnode.props.style && typeof (vnode.props.style) === 'object') {
for (let styleKey in vnode.props.style) { for (let styleKey in vnode.props.style) {
host.style[ styleKey ] = vnode.props.style[ styleKey ]; host.style[ styleKey ] = vnode.props.style[ styleKey ];
@ -54,7 +65,19 @@ export function render(vnode, opts = {}) {
if(!VNODEPROP_EXCLUDE_DIRECT[key] && !key.indexOf('-')){ if(!VNODEPROP_EXCLUDE_DIRECT[key] && !key.indexOf('-')){
host[key] = val; host[key] = val;
} }
if (val === false) { 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); host.removeAttribute(key);
} else if (val === true) { } else if (val === true) {
host.setAttribute(key, ""); host.setAttribute(key, "");
@ -64,27 +87,32 @@ export function render(vnode, opts = {}) {
} }
} }
} }
}
// Children // Children
if (vnode.children) { if (vnode?.children) {
let children = vnode.children instanceof Array? vnode.children : [vnode.children]; let queue = vnode.children instanceof Array? vnode.children.slice() : [vnode.children];
while(queue.length){
for(let child of children){ 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, { let el = child instanceof Element? child : render(child, {
...opts, ...opts,
host: undefined host: undefined
}); });
if(el!==undefined){ if(el!==undefined) host.appendChild(el);
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; return host;

View File

@ -21,7 +21,9 @@ export default [
plugins: [ plugins: [
sass(), sass(),
babel(), // babel babel(), // babel
resolve(), // node_modules resolve({
extensions: [ '.mjs', '.js', '.jsx', '.json' ],
}), // node_modules
commonjs(), // CJS-modules commonjs(), // CJS-modules
production && terser(), // minify, but only in production production && terser(), // minify, but only in production
copy({ copy({
@ -43,7 +45,9 @@ export default [
plugins: [ plugins: [
sass(), sass(),
babel(), // babel babel(), // babel
resolve(), // node_modules resolve({
extensions: [ '.mjs', '.js', '.jsx', '.json' ],
}), // node_modules
commonjs(), // CJS-modules commonjs(), // CJS-modules
production && terser(), // minify, but only in production production && terser(), // minify, but only in production
copy({ copy({

View File

@ -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') @defineElement('example-page')
export class ExamplePage extends CustomElement{ export class ExamplePage extends CustomElement{

View File

@ -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 (
<Host>
<style>{ style }</style>
<h1>CSX Todo</h1>
<section>
<todo-input onSubmit={this.handleSubmit}/>
<ul id="list-container"
onCheck={this.handleCheck}
onRemove={this.handleRemove}
>
{this.todos.map(item =>
<todo-item
model={ item.id }
checked={( item.checked )}
>{ item.text }</todo-item>
)}
</ul>
</section>
</Host>
);
}
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)];
}
}

View File

@ -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;
}

View File

@ -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 (
<Host>
<style>{ style }</style>
<form onSubmit={ this.handleSubmit }>
<input
value={this.value}
type="text"
placeholder="What needs to be done?"
onInput={this.handleInput}
/>
</form>
</Host>
)
}
handleSubmit = (e)=>{
e.preventDefault();
if (!this.value) return;
this.dispatchEvent(new CustomEvent('submit', {
detail: this.value
}));
this.state = "";
};
handleInput = ({target: {value}})=>{
this.value = value;
};
}

View File

@ -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;
}

View File

@ -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 (
<Host>
<ShadowDOM>
<style>{ style }</style>
<li class={( this.checked ? 'completed' : '' )}>
<input
type="checkbox" checked={ this.checked }
onChange={this.handleChange}
/>
<label>
<slot />
</label>
<button onClick={this.handleClick}>x</button>
</li>
</ShadowDOM>
</Host>
);
}
handleChange = ()=>{
this.dispatchEvent(new CustomEvent('check', {
detail: (this.checked=!this.checked)
}));
};
handleClick = ()=>{
this.dispatchEvent(new CustomEvent('remove', {
}));
};
}

View File

@ -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,<svg%20xmlns%3D"http%3A//www.w3.org/2000/svg"%20width%3D"40"%20height%3D"40"%20viewBox%3D"-10%20-18%20100%20135"><circle%20cx%3D"50"%20cy%3D"50"%20r%3D"50"%20fill%3D"none"%20stroke%3D"%23ededed"%20stroke-width%3D"3"/></svg>');
}
li input:checked:after {
content: url('data:image/svg+xml;utf8,<svg%20xmlns%3D"http%3A//www.w3.org/2000/svg"%20width%3D"40"%20height%3D"40"%20viewBox%3D"-10%20-18%20100%20135"><circle%20cx%3D"50"%20cy%3D"50"%20r%3D"50"%20fill%3D"none"%20stroke%3D"%23bddad5"%20stroke-width%3D"3"/><path%20fill%3D"%235dc2af"%20d%3D"M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z"/></svg>');
}
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;
}

View File

@ -1,9 +1,10 @@
import {render} from "../../packages/csx-custom-elements"; import {render} from "../../packages/csx-custom-elements";
import style from "./index.scss"; import style from "./index.scss";
import {MyTodo} from "./components/my-todo";
// Replace this with an example implementation of the Todos-MVC app // Replace this with an example implementation of the Todos-MVC app
// look for inspiration here: https://github.com/shprink/web-components-todo // look for inspiration here: https://github.com/shprink/web-components-todo
document.body.appendChild(render(<style>{style}</style>)); document.body.appendChild(render(<style>{style}</style>));
document.body.appendChild(render(<div class="center-me"> document.body.appendChild(render(<div class="center-me">
<h1>Todos MVC</h1> <MyTodo />
</div>)); </div>));