├── .eslintrc.json ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── demos ├── counter │ ├── index.html │ └── index.js ├── forms │ ├── form.js │ ├── index.html │ └── index.js ├── localState │ ├── index.html │ ├── index.js │ └── panel.js ├── simple router │ ├── about.js │ ├── counter.js │ ├── home.js │ ├── index.html │ ├── index.js │ ├── package-lock.json │ ├── package.json │ └── router.js └── svg │ ├── index.html │ └── index.js ├── package.json └── src ├── debugState.js └── index.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "ecmaFeatures": { 4 | "modules": true, 5 | "spread": true, 6 | "restParams": true 7 | }, 8 | "env": { 9 | "browser": true, 10 | "node": false, 11 | "es6": true 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 9, 15 | "sourceType": "module" 16 | }, 17 | "rules": { 18 | "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Node specific modules 64 | cjs/ 65 | 66 | # parcel files 67 | .cache 68 | dist/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # dev-oly folders 2 | .babelrc* 3 | .eslintrc* 4 | .bookignore 5 | book.json 6 | test 7 | examples 8 | node_modules 9 | 10 | # doc folders 11 | test 12 | docs 13 | examples 14 | _book 15 | 16 | # parcel files 17 | .cache 18 | dist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Yassine Elouafi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > Caution! this is isn't something ready for production; you may try it though in a side project 2 | 3 | # Getting starts 4 | 5 | ```sh 6 | npm i --save mobx-static-dom 7 | ``` 8 | 9 | # Simple Demo 10 | 11 | [Sandbox demo](https://codesandbox.io/s/98rwoq150o) 12 | 13 | ```js 14 | import { h, p, on, render } from "mobx-static-dom"; 15 | import { observable } from "mobx"; 16 | 17 | const state = observable({ 18 | count: 0 19 | }); 20 | 21 | export const counterApp = h.h1( 22 | p.style("cursor: pointer"), 23 | () => `Count ${state.count}`, 24 | on.click(() => state.count++) 25 | ); 26 | 27 | // assuming your html file contains an element with id="app" 28 | render(counterApp, document.getElementById("app")); 29 | ``` 30 | 31 | - Use `h` to create HTML elements 32 | - Use `p` to set DOM properties 33 | - Use `on` to attach event handlers 34 | - Use mobx to create observable values (values that will be changed by your app) 35 | - When createing HTML elements, wrap dynamic values in a function (`() => state.count`) 36 | - Call `render` to append the created element 37 | 38 | > Do not append the create elements directly to the parent DOM, it won't work 39 | 40 | # Dynamic lists of elements 41 | 42 | [Sandbox demo](https://codesandbox.io/s/o9j0v3y9jy) 43 | 44 | ```js 45 | import { h, p, on, map, render } from "mobx-static-dom"; 46 | import { observable, computed } from "mobx"; 47 | 48 | const state = observable({ 49 | input: "", 50 | todos: [] 51 | }); 52 | 53 | function addTodo() { 54 | state.todos.push({ title: state.input, done: false }); 55 | state.input = ""; 56 | } 57 | 58 | export const todoApp = h.div( 59 | h.input( 60 | p.value(() => state.input), 61 | on.input(event => (state.input = event.target.value)), 62 | on.keydown(event => { 63 | if (event.which === 13) addTodo(); 64 | }) 65 | ), 66 | map(() => state.todos, todoView) 67 | ); 68 | 69 | function todoView(todo) { 70 | return h.label( 71 | p.style( 72 | () => ` 73 | display: block; 74 | text-decoration: ${todo.done ? "line-through" : "none"}` 75 | ), 76 | p.for("cb-done"), 77 | h.input( 78 | p.type("checkbox"), 79 | p.id("cb-done"), 80 | p.checked(() => todo.done), 81 | on.click(event => (todo.done = event.target.checked)) 82 | ), 83 | () => todo.title 84 | ); 85 | } 86 | 87 | render(todoApp, document.getElementById("app")); 88 | ``` 89 | 90 | Notes 91 | 92 | - Use `map` to render dynamic arrays 93 | - Event handlers are automatically wrapped with mobx actions 94 | 95 | # Local state 96 | 97 | [Sandbox demo](https://codesandbox.io/s/0qro7vz60n) 98 | 99 | ```js 100 | function panel(...children) { 101 | const state = observable({ 102 | isContentVisible: true 103 | }); 104 | 105 | function toggleContent() { 106 | state.isContentVisible = !state.isContentVisible; 107 | } 108 | 109 | return h.section( 110 | h.button( 111 | () => (state.isContentVisible ? "Hide content" : "Show content"), 112 | on.click(toggleContent) 113 | ), 114 | h.div( 115 | p.style(() => (state.isContentVisible ? visibleStyle : collapsedStyle)), 116 | ...children 117 | ) 118 | ); 119 | } 120 | ``` 121 | 122 | - Simply declare local state inside your function 123 | - Children elements are passed as ordinary function arguments 124 | 125 | # Simple router (dynamic element) 126 | 127 | [Sandbox demo](https://codesandbox.io/s/kw5lzwzj2r) 128 | 129 | ```js 130 | import { h, p, on, dynamic } from "mobx-static-dom"; 131 | import { observable } from "mobx"; 132 | import { createBrowserHistory } from "history"; 133 | 134 | export const history = createBrowserHistory(); 135 | 136 | export function router(config) { 137 | const state = observable({ 138 | currentView: config(history.location.pathname) 139 | }); 140 | 141 | history.listen((location, action) => { 142 | console.log(action, location.pathname, location.state); 143 | state.currentView = config(history.location.pathname); 144 | }); 145 | 146 | // switch the current view 147 | return dynamic(() => state.currentView); 148 | } 149 | 150 | export function link(path, label) { 151 | return h.a( 152 | label, 153 | p.href("#"), 154 | on.click(event => { 155 | event.preventDefault(); 156 | if (path === history.location.pathname) return; 157 | history.push(path); 158 | }) 159 | ); 160 | } 161 | ``` 162 | 163 | - Use `dynamic` to create dynamically changing HTML elements 164 | - Note the `dynamic` dierctive mounts a new instance of the wrapped element, so the internal state is gone when you switch back to the same element. 165 | 166 | -------------------------------------------------------------------------------- /demos/counter/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Counter demo 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /demos/counter/index.js: -------------------------------------------------------------------------------- 1 | import { h, style, on, render } from "../../src"; 2 | import { observable } from "mobx"; 3 | 4 | const state = observable({ 5 | count: 0 6 | }); 7 | 8 | export const counterApp = h.h1( 9 | style.cursor("pointer"), 10 | () => `Count ${state.count}`, 11 | on.click(() => state.count++) 12 | ); 13 | 14 | // assuming your html file contains an element with id="app" 15 | render(counterApp, document.getElementById("app")); 16 | -------------------------------------------------------------------------------- /demos/forms/form.js: -------------------------------------------------------------------------------- 1 | import { on, provider, directive } from "../../src"; 2 | import { observable, reaction } from "mobx"; 3 | 4 | function success(value) { 5 | return { type: "success", value }; 6 | } 7 | 8 | function error(errorMessage, invalidValue) { 9 | return { type: "error", errorMessage, value: invalidValue }; 10 | } 11 | 12 | const SPACES_RE = /\s+/g; 13 | const INTEGER_RE = /^\d+$/; 14 | const DECIMAL_RE = /^\d+(?:\.\d+)?$/; 15 | const DATE_RE = /^(?:\d{1,2})\/(?:\d{1,2})\/(?:\d{1,4})$/; 16 | 17 | export const types = { 18 | text: { 19 | parse: success, 20 | format: str => str 21 | }, 22 | 23 | integer: { 24 | parse(str) { 25 | str = str.replace(SPACES_RE, ""); 26 | if (!INTEGER_RE.test(str)) return error("Invalid integer!"); 27 | return success(+str); 28 | }, 29 | format(num, isFocused, intl) { 30 | return isFocused 31 | ? num 32 | : intl.formatNumber(num, { maximumFractionDigits: 0 }); 33 | } 34 | }, 35 | 36 | decimal: { 37 | parse(str) { 38 | str = str.replace(SPACES_RE, ""); 39 | if (!DECIMAL_RE.test(str)) return error("Invalid decimal!"); 40 | return success(+str); 41 | }, 42 | format(num) { 43 | return num.toFixed(2); 44 | } 45 | }, 46 | 47 | date: { 48 | parse(str) { 49 | if (!DATE_RE.test(str)) return error("Invalid date!"); 50 | const parts = str.split("/"); 51 | if (parts.length === 3) { 52 | let [day, month, year] = parts.map(s => +s); 53 | if (year < 100) { 54 | year += 2000; 55 | } 56 | const value = Date.parse(`${year}-${month}-${day}`); 57 | if (!isNaN(value)) return success(value); 58 | } 59 | return error("Invalid date!"); 60 | }, 61 | format(timestamp, _, intl) { 62 | return intl.formatDate(new Date(timestamp)); 63 | } 64 | } 65 | }; 66 | 67 | export const validation = { 68 | min(aMin, msg = `Field must be greater or equal than ${aMin}`) { 69 | return function minValidator(num) { 70 | if (num < aMin) return msg; 71 | }; 72 | }, 73 | max(aMax, msg = `Field must be less or equal than ${aMax}`) { 74 | return function maxValidator(num) { 75 | if (num > aMax) return msg; 76 | }; 77 | }, 78 | minSize( 79 | aMinSize, 80 | msg = `Field be must have ${aMinSize} charachters at least` 81 | ) { 82 | return function minSizeValidator(str) { 83 | if (str.length < aMinSize) return msg; 84 | }; 85 | }, 86 | maxSize(aMaxSize, msg = `Field must have ${aMaxSize} charachters at most`) { 87 | return function maxSizeValidator(str) { 88 | if (str.length > aMaxSize) return msg; 89 | }; 90 | }, 91 | pattern(aPattern, msg = `Field value doesn't match ${aPattern}`) { 92 | const anchoredRegex = new RegExp(`^${aPattern}$`); 93 | return function patternValidator(str) { 94 | if (!anchoredRegex.test(str)) return msg; 95 | }; 96 | } 97 | }; 98 | 99 | validation.email = validation.pattern( 100 | /(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/ 101 | .source, 102 | "Invalid email address" 103 | ); 104 | 105 | export function form(render) { 106 | const state = observable({ 107 | $$fields: [], 108 | wasSubmitted: false, 109 | get isTouched() { 110 | return state.$$fields.some(field => field.isTouched); 111 | }, 112 | get hasErrors() { 113 | return state.$$fields.some(field => field.currentResult.type === "error"); 114 | } 115 | }); 116 | 117 | function handleSubmit() { 118 | state.wasSubmitted = true; 119 | } 120 | 121 | const formContext = { 122 | form: state 123 | }; 124 | 125 | return provider(formContext, render(state, handleSubmit)); 126 | } 127 | 128 | export function textInput({ 129 | required, 130 | type, 131 | validate, 132 | getValue, 133 | onChange, 134 | render 135 | }) { 136 | let state = observable({ 137 | name, 138 | text: String(getValue()), 139 | currentResult: processInput(String(getValue())), 140 | isFocused: false, 141 | isTouched: false 142 | }); 143 | 144 | reaction(getValue, newValue => { 145 | state.text = String(newValue); 146 | }); 147 | 148 | function processInput(text) { 149 | if (text === "" && required) { 150 | if (required) return error(required); 151 | return success(null); 152 | } 153 | const result = type.parse(text); 154 | if (result.type === "error" || validate == null) return result; 155 | const errMsg = validate(result.value); 156 | if (errMsg != null) return error(errMsg, result.value); 157 | return result; 158 | } 159 | 160 | function handleInput(event) { 161 | state.isTouched = true; 162 | state.text = event.target.value; 163 | state.currentResult = processInput(state.text); 164 | } 165 | 166 | function handleFocus() { 167 | state.isFocused = true; 168 | } 169 | 170 | function handleBlur() { 171 | state.isFocused = false; 172 | if (state.currentResult.type === "success") { 173 | onChange(state.currentResult.value); 174 | } 175 | } 176 | 177 | return render(state, [ 178 | on.input(handleInput), 179 | on.input(handleFocus), 180 | on.input(handleBlur), 181 | directive(env => env.ctx.form.$$fields.push(state)) 182 | ]); 183 | } 184 | -------------------------------------------------------------------------------- /demos/forms/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Local state demo 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /demos/forms/index.js: -------------------------------------------------------------------------------- 1 | import { h, p, on, style, render } from "../../src"; 2 | import { observable } from "mobx"; 3 | import { form, textInput, types, validation } from "./form"; 4 | import { debugState } from "../../src/debugState"; 5 | 6 | function field(label, props) { 7 | return h.div( 8 | style.marginBottom("1em"), 9 | h.label(label, p.labelFor(label), style.display("block")), 10 | textInput( 11 | Object.assign({}, props, { 12 | render(inputState, inputHandlers) { 13 | function canShowError() { 14 | return inputState.currentResult.type === "error"; 15 | } 16 | return h.div( 17 | h.input( 18 | p.id(name), 19 | style.border("1px solid #ddd"), 20 | style.borderRadius("2px"), 21 | style.display("block"), 22 | style.width("100%"), 23 | style.padding("5px"), 24 | p.value(() => inputState.text), 25 | inputHandlers 26 | ), 27 | h.span( 28 | style.fontSize("80%"), 29 | style.marginTop("5px"), 30 | style.display(() => (canShowError() ? "block" : "none")), 31 | style.color("red"), 32 | () => inputState.currentResult.errorMessage 33 | ) 34 | ); 35 | } 36 | }) 37 | ) 38 | ); 39 | } 40 | 41 | const state = (window.$state = observable({ 42 | values: { 43 | name: "", 44 | age: 40, 45 | email: "" 46 | } 47 | })); 48 | 49 | const app = h.section( 50 | p.style(` 51 | max-width: 600px; 52 | margin: auto; 53 | font-size: 18px; 54 | `), 55 | h.h1("Contact form"), 56 | form((formState, onSubmit) => 57 | h.form( 58 | field("Name", { 59 | name: "name", 60 | required: "Name is required", 61 | type: types.text, 62 | validate: validation.minSize(10), 63 | getValue: () => state.values.name, 64 | onChange: v => (state.values.name = v) 65 | }), 66 | field("Email", { 67 | name: "email", 68 | required: "Email is required", 69 | type: types.text, 70 | validate: validation.email, 71 | getValue: () => state.values.email, 72 | onChange: v => (state.values.email = v) 73 | }), 74 | field( 75 | "Age", 76 | { 77 | name: "age", 78 | required: "Please provide an age", 79 | type: types.integer, 80 | validate: validation.min(15), 81 | getValue: () => state.values.age, 82 | onChange: v => (state.values.age = v) 83 | }, 84 | style.maxWidth("60px") 85 | ), 86 | h.button( 87 | "Submit", 88 | p.type("submit"), 89 | p.disabled(() => formState.hasErrors), 90 | on.click(event => { 91 | event.preventDefault(); 92 | console.log("submit"); 93 | onSubmit(); 94 | }) 95 | ), 96 | debugState(() => formState) 97 | ) 98 | ) 99 | ); 100 | 101 | // assuming your html file contains an element with id="app" 102 | render(app, document.getElementById("app")); 103 | -------------------------------------------------------------------------------- /demos/localState/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Local state demo 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /demos/localState/index.js: -------------------------------------------------------------------------------- 1 | import { h, p, on, render } from "../../src"; 2 | import { observable } from "mobx"; 3 | import debounce from "lodash.debounce"; 4 | import { markdown } from "markdown"; 5 | import { panel } from "./panel"; 6 | 7 | const state = observable({ 8 | markdown: `# What is Lorem Ipsum?\n 9 | **Lorem Ipsum** is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.` 10 | }); 11 | 12 | export const app = h.main( 13 | p.style(` 14 | max-width: 600px; 15 | margin: auto; 16 | margin-top: 2em; 17 | `), 18 | h.h1("Simple Markdown editor"), 19 | h.p("Source"), 20 | h.textarea( 21 | { rows: 12, style: "width: 100%" }, 22 | state.markdown, 23 | on.input( 24 | debounce(event => { 25 | state.markdown = event.target.value; 26 | }, 300) 27 | ) 28 | ), 29 | h.hr(), 30 | panel(h.div(p.innerHTML(() => markdown.toHTML(state.markdown)))) 31 | ); 32 | 33 | // assuming your html file contains an element with id="app" 34 | render(app, document.getElementById("app")); 35 | -------------------------------------------------------------------------------- /demos/localState/panel.js: -------------------------------------------------------------------------------- 1 | import { h, p, on } from "../../src"; 2 | import { observable } from "mobx"; 3 | 4 | const visibleStyle = ` 5 | overflow:hidden; 6 | transition:max-height 0.5s ease-out; 7 | max-height:600px; 8 | `; 9 | 10 | const collapsedStyle = ` 11 | overflow:hidden; 12 | max-height: 0; 13 | `; 14 | 15 | export function panel(...children) { 16 | const state = observable({ 17 | isContentVisible: true 18 | }); 19 | 20 | function toggleContent() { 21 | state.isContentVisible = !state.isContentVisible; 22 | } 23 | 24 | return h.section( 25 | h.button( 26 | () => (state.isContentVisible ? "Hide content" : "Show content"), 27 | on.click(toggleContent) 28 | ), 29 | h.div( 30 | p.style(() => (state.isContentVisible ? visibleStyle : collapsedStyle)), 31 | ...children 32 | ) 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /demos/simple router/about.js: -------------------------------------------------------------------------------- 1 | import { h, p, on } from "../../src"; 2 | 3 | export function about() { 4 | return h.section(h.h1("About me?"), h.p("Nothing interesting")); 5 | } 6 | -------------------------------------------------------------------------------- /demos/simple router/counter.js: -------------------------------------------------------------------------------- 1 | import { h, p, on } from "../../src"; 2 | import { observable } from "mobx"; 3 | 4 | export function counter() { 5 | const state = observable({ 6 | count: 0 7 | }); 8 | 9 | return h.section( 10 | h.h1("Counter demo"), 11 | h.p("Notice the state of this is gone whene navigated away"), 12 | h.h3(p.style("cursor: pointer"), () => `Count ${state.count}`), 13 | h.button("Increment", on.click(() => state.count++)), 14 | h.button("Decrement", on.click(() => state.count--)) 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /demos/simple router/home.js: -------------------------------------------------------------------------------- 1 | import { h, p, on } from "../../src"; 2 | 3 | export function home(...children) { 4 | return h.section(h.h1("Welcome home"), children); 5 | } 6 | -------------------------------------------------------------------------------- /demos/simple router/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Simple routing demo 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /demos/simple router/index.js: -------------------------------------------------------------------------------- 1 | import { h, p, on, render } from "../../src"; 2 | import { observable } from "mobx"; 3 | import { router, link } from "./router"; 4 | 5 | import { home } from "./home"; 6 | import { counter } from "./counter"; 7 | import { about } from "./about"; 8 | 9 | const app = h.div( 10 | h.h2("Simple router demo using dynamic element"), 11 | h.ul( 12 | h.li(link("/", "Home")), 13 | h.li(link("/counter", "Counter demo")), 14 | h.li(link("/about", "About")) 15 | ), 16 | router(path => { 17 | if (path === "/" || path === "/home") return home(); 18 | if (path === "/counter") return counter(); 19 | if (path === "/about") return about(); 20 | else 21 | return h.h1( 22 | "Error! unkown path, try one of the links above", 23 | p.style("background-color: red; color: #fefefe") 24 | ); 25 | }) 26 | ); 27 | 28 | render(app, document.getElementById("app")); 29 | -------------------------------------------------------------------------------- /demos/simple router/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-router", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "history": { 8 | "version": "4.7.2", 9 | "resolved": "https://registry.npmjs.org/history/-/history-4.7.2.tgz", 10 | "integrity": "sha512-1zkBRWW6XweO0NBcjiphtVJVsIQ+SXF29z9DVkceeaSLVMFXHool+fdCZD4spDCfZJCILPILc3bm7Bc+HRi0nA==", 11 | "requires": { 12 | "invariant": "^2.2.1", 13 | "loose-envify": "^1.2.0", 14 | "resolve-pathname": "^2.2.0", 15 | "value-equal": "^0.4.0", 16 | "warning": "^3.0.0" 17 | } 18 | }, 19 | "invariant": { 20 | "version": "2.2.4", 21 | "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", 22 | "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", 23 | "requires": { 24 | "loose-envify": "^1.0.0" 25 | } 26 | }, 27 | "js-tokens": { 28 | "version": "4.0.0", 29 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 30 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 31 | }, 32 | "loose-envify": { 33 | "version": "1.4.0", 34 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 35 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 36 | "requires": { 37 | "js-tokens": "^3.0.0 || ^4.0.0" 38 | } 39 | }, 40 | "resolve-pathname": { 41 | "version": "2.2.0", 42 | "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-2.2.0.tgz", 43 | "integrity": "sha512-bAFz9ld18RzJfddgrO2e/0S2O81710++chRMUxHjXOYKF6jTAMrUNZrEZ1PvV0zlhfjidm08iRPdTLPno1FuRg==" 44 | }, 45 | "value-equal": { 46 | "version": "0.4.0", 47 | "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-0.4.0.tgz", 48 | "integrity": "sha512-x+cYdNnaA3CxvMaTX0INdTCN8m8aF2uY9BvEqmxuYp8bL09cs/kWVQPVGcA35fMktdOsP69IgU7wFj/61dJHEw==" 49 | }, 50 | "warning": { 51 | "version": "3.0.0", 52 | "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz", 53 | "integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=", 54 | "requires": { 55 | "loose-envify": "^1.0.0" 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /demos/simple router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-router", 3 | "version": "1.0.0", 4 | "description": "simple router using dynamic directive", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "MIT", 11 | "dependencies": { 12 | "history": "^4.7.2" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /demos/simple router/router.js: -------------------------------------------------------------------------------- 1 | import { h, p, on, dynamic } from "../../src"; 2 | import { observable } from "mobx"; 3 | import { createBrowserHistory } from "history"; 4 | 5 | export const history = createBrowserHistory(); 6 | 7 | export function router(config) { 8 | const state = observable({ 9 | currentView: config(history.location.pathname) 10 | }); 11 | 12 | history.listen((location, action) => { 13 | console.log(action, location.pathname, location.state); 14 | state.currentView = config(history.location.pathname); 15 | }); 16 | 17 | return dynamic(() => state.currentView); 18 | } 19 | 20 | export function link(path, label) { 21 | return h.a( 22 | label, 23 | p.href("#"), 24 | on.click(event => { 25 | event.preventDefault(); 26 | if (path === history.location.pathname) return; 27 | history.push(path); 28 | }) 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /demos/svg/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SVG demo 5 | 6 | 29 | 30 | 31 | 32 |
33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /demos/svg/index.js: -------------------------------------------------------------------------------- 1 | import { h, style, directive, render } from "../../src"; 2 | import { observable } from "mobx"; 3 | 4 | function draggable(getX, getY, onDrag) { 5 | return directive(({ parent: node }) => { 6 | node.onmousedown = event => { 7 | const dx = getX() - event.clientX; 8 | const dy = getY() - event.clientY; 9 | 10 | document.addEventListener("mousemove", onMouseMove); 11 | 12 | node.onmouseup = () => { 13 | document.removeEventListener("mousemove", onMouseMove); 14 | }; 15 | 16 | function onMouseMove(event) { 17 | const x = event.clientX + dx; 18 | const y = event.clientY + dy; 19 | onDrag(x, y); 20 | } 21 | }; 22 | }); 23 | } 24 | 25 | const colors = ["#28A86B", "#ff5722", "b06327", "#1e99e9"]; 26 | 27 | function controlPoint(getX, getY, fill, onDrag) { 28 | return h.g( 29 | { transform: () => `translate(${getX()}, ${getY()})` }, 30 | h.circle({ 31 | cx: 0, 32 | cy: 0, 33 | r: 3, 34 | class: "control-point", 35 | style: `fill: ${fill}` 36 | }), 37 | h.circle( 38 | { cx: 0, cy: 0, r: 10, class: "handle" }, 39 | draggable(getX, getY, onDrag) 40 | ) 41 | ); 42 | } 43 | 44 | function bezier(state) { 45 | return [ 46 | h.g( 47 | h.path({ 48 | d: () => 49 | `M${state.x1} ${state.y1} C${state.cx1} ${state.cy1} ${state.cx2} ${ 50 | state.cy2 51 | } ${state.x2} ${state.y2}`, 52 | class: "curve" 53 | }), 54 | h.line({ 55 | class: "control-line", 56 | x1: () => state.cx1, 57 | y1: () => state.cy1, 58 | x2: () => state.x1, 59 | y2: () => state.y1 60 | }), 61 | h.line({ 62 | class: "control-line", 63 | x1: () => state.x2, 64 | y1: () => state.y2, 65 | x2: () => state.cx2, 66 | y2: () => state.cy2 67 | }) 68 | ), 69 | controlPoint(() => state.x1, () => state.y1, colors[0], (x, y) => { 70 | state.x1 = x; 71 | state.y1 = y; 72 | }), 73 | controlPoint(() => state.x2, () => state.y2, colors[1], (x, y) => { 74 | state.x2 = x; 75 | state.y2 = y; 76 | }), 77 | controlPoint(() => state.cx1, () => state.cy1, colors[2], (x, y) => { 78 | state.cx1 = x; 79 | state.cy1 = y; 80 | }), 81 | controlPoint(() => state.cx2, () => state.cy2, colors[3], (x, y) => { 82 | state.cx2 = x; 83 | state.cy2 = y; 84 | }) 85 | ]; 86 | } 87 | const state = observable({ 88 | x1: 100, 89 | y1: 100, 90 | x2: 280, 91 | y2: 100, 92 | cx1: 150, 93 | cy1: 20, 94 | cx2: 180, 95 | cy2: 150 96 | }); 97 | 98 | export const svgApp = h.div( 99 | h.svg({ width: 500, height: 240 }, bezier(state)), 100 | h.pre( 101 | ' `${state.cx1},${state.cy1} `, style.color(colors[2])), 105 | h.span(() => `${state.cx2},${state.cy2} `, style.color(colors[3])), 106 | h.span(() => `${state.x2},${state.y2}`, style.color(colors[1])), 107 | '" />' 108 | ) 109 | ); 110 | 111 | // assuming your html file contains an element with id="app" 112 | render(svgApp, document.getElementById("app")); 113 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mobx-static-dom", 3 | "version": "0.5.0", 4 | "description": "Dynamic UIs using mobx and static DOM", 5 | "module": "src/index.js", 6 | "repository": "https://github.com/yelouafi/mobx-static-dom.git", 7 | "author": "Yassine Elouafi ", 8 | "license": "MIT", 9 | "keywords": [ 10 | "mobx", 11 | "dom", 12 | "directives", 13 | "UI" 14 | ], 15 | "peerDependencies": { 16 | "mobx": "^4.0.0 || ^5.0.0" 17 | }, 18 | "devDependencies": { 19 | "eslint": "^5.8.0" 20 | }, 21 | "scripts": { 22 | "test": "echo no-tests-for-now", 23 | "check": "npm run test", 24 | "prerelease": "npm run check", 25 | "release:patch": "npm run prerelease && npm version patch && git push --follow-tags && npm publish", 26 | "release:minor": "npm run prerelease && npm version minor && git push --follow-tags && npm publish", 27 | "release:major": "npm run prerelease && npm version major && git push --follow-tags && npm publish" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/debugState.js: -------------------------------------------------------------------------------- 1 | import { directive, h, p } from "./index"; 2 | import { toJS } from "mobx"; 3 | 4 | const debugParent = document.createElement("debug"); 5 | document.body.appendChild(debugParent); 6 | 7 | export function debugState(getState, ...rest) { 8 | return directive(function debugStateDirective(env) { 9 | const debugDir = h.div( 10 | p.style(` 11 | position: absolute; 12 | bottom: 10px; 13 | right: 10px; 14 | max-width: 400px; 15 | max-height: 200px; 16 | overflow: auto; 17 | border: 1px solid #ddd; 18 | border-radius: 5px; 19 | padding: 5px; 20 | box-shadow: 10px 10px 12px 0px rgba(0,0,0,0.75); 21 | `), 22 | h.pre(() => JSON.stringify(toJS(getState()), null, 2)), 23 | ...rest 24 | ); 25 | debugParent.textContent = ""; 26 | debugDir({ ...env, parent: debugParent }); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { autorun, action } from "mobx"; 4 | 5 | const DIRECTIVE = Symbol("Directive"); 6 | 7 | export function directive(f) { 8 | if (typeof f !== "function") throw new Error("argument must be a function!"); 9 | f[DIRECTIVE] = true; 10 | return f; 11 | } 12 | 13 | export const isDirective = v => v != null && v[DIRECTIVE]; 14 | 15 | function isPlainObject(obj) { 16 | if (typeof obj !== "object" || obj === null) return false; 17 | 18 | const proto = Object.getPrototypeOf(obj); 19 | return proto !== null && Object.getPrototypeOf(proto) === null; 20 | } 21 | 22 | function kebabCase(str) { 23 | return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); 24 | } 25 | 26 | export function text(value) { 27 | return directive(function textNodeDirective(env) { 28 | if (typeof value !== "function") { 29 | env.parent.appendChild(document.createTextNode(value)); 30 | } else { 31 | const node = document.createTextNode(""); 32 | env.onDispose( 33 | env.subscribe(() => { 34 | node.nodeValue = value(env.ctx); 35 | }) 36 | ); 37 | env.parent.appendChild(node); 38 | } 39 | }); 40 | } 41 | 42 | export function prop(name, value) { 43 | return directive(function propDirective(env) { 44 | if (typeof value !== "function") { 45 | env.parent[name] = value; 46 | } else { 47 | env.onDispose( 48 | env.subscribe(() => { 49 | env.parent[name] = value(env.ctx); 50 | }) 51 | ); 52 | } 53 | }); 54 | } 55 | 56 | const SVG_NS = "http://www.w3.org/2000/svg"; 57 | const XLINK_NS = "http://www.w3.org/1999/xlink"; 58 | const NS_ATTRS = { 59 | show: XLINK_NS, 60 | actuate: XLINK_NS, 61 | href: XLINK_NS 62 | }; 63 | 64 | function setDOMAttribute(el, name, value, isSVG) { 65 | if (value === true) { 66 | el.setAttribute(name, ""); 67 | } else if (value === false) { 68 | el.removeAttribute(name); 69 | } else { 70 | var ns = isSVG ? NS_ATTRS[name] : undefined; 71 | if (ns !== undefined) { 72 | el.setAttributeNS(ns, name, value); 73 | } else { 74 | el.setAttribute(name, value); 75 | } 76 | } 77 | } 78 | 79 | export function attr(name, value) { 80 | return directive(function attributeDirective(env) { 81 | if (typeof value !== "function") { 82 | setDOMAttribute(env.parent, name, value, env.ctx.SVG); 83 | } else { 84 | env.onDispose( 85 | env.subscribe(() => { 86 | setDOMAttribute(env.parent, name, value(env.ctx), env.ctx.SVG); 87 | }) 88 | ); 89 | } 90 | }); 91 | } 92 | 93 | export function styleKey(name, value) { 94 | return directive(function propDirective(env) { 95 | if (typeof value !== "function") { 96 | env.parent.style[name] = value; 97 | } else { 98 | env.onDispose( 99 | env.subscribe(() => { 100 | env.parent.style[name] = value(env.ctx); 101 | }) 102 | ); 103 | } 104 | }); 105 | } 106 | 107 | export function event(type, handler) { 108 | return directive(function eventDirective(env) { 109 | env.parent.addEventListener(type, action(event => handler(event, env.ctx))); 110 | }); 111 | } 112 | 113 | function runChild(child, env) { 114 | if (isDirective(child)) { 115 | child(env); 116 | } else if (Array.isArray(child)) { 117 | child.forEach(it => runChild(it, env)); 118 | } else if (isPlainObject(child)) { 119 | Object.keys(child).forEach(key => attr(kebabCase(key), child[key])(env)); 120 | } else { 121 | text(child)(env); 122 | } 123 | } 124 | 125 | export function el(tag, ...children) { 126 | return directive(function elementDirective(env) { 127 | const isSvgTag = tag === "svg"; 128 | const envIsSvg = env.ctx.SVG; 129 | const node = 130 | isSvgTag || envIsSvg 131 | ? document.createElementNS(SVG_NS, tag) 132 | : document.createElement(tag); 133 | const childEnv = { ...env, parent: node }; 134 | if (isSvgTag && !envIsSvg) { 135 | childEnv.ctx = { ...env.ctx, SVG: true }; 136 | } 137 | runChild(children, childEnv); 138 | env.parent.appendChild(node); 139 | }); 140 | } 141 | 142 | export function map(getItems, template) { 143 | return directive(function mapDirective(env) { 144 | let items = []; 145 | let imap = new Map(); 146 | let refs = []; 147 | const endMarkNode = document.createComment("array-end"); 148 | env.parent.appendChild(endMarkNode); 149 | const disposer = env.subscribe(syncChildren); 150 | env.onDispose(() => { 151 | disposer(); 152 | refs.forEach(ref => ref.dispose()); 153 | }); 154 | 155 | function syncChildren() { 156 | let oldItems = items; 157 | let oldRefs = refs; 158 | items = getItems(env.ctx); 159 | refs = new Array(items.length); 160 | let oldStart = 0, 161 | oldEnd = oldItems.length - 1; 162 | let newStart = 0, 163 | newEnd = items.length - 1; 164 | let oldIt, newIt, oldRef, newRef; 165 | let nextSibling = endMarkNode; 166 | 167 | while (oldStart <= oldEnd && newStart <= newEnd) { 168 | if (oldItems[oldStart] == null) { 169 | oldStart++; 170 | continue; 171 | } 172 | if (oldItems[oldEnd] == null) { 173 | oldEnd--; 174 | continue; 175 | } 176 | if (oldItems[oldStart] === items[newStart]) { 177 | refs[newStart] = oldRefs[oldStart]; 178 | oldStart++; 179 | newStart++; 180 | continue; 181 | } 182 | if (oldItems[oldEnd] === items[newEnd]) { 183 | refs[newEnd] = oldRefs[oldEnd]; 184 | oldEnd--; 185 | newEnd--; 186 | continue; 187 | } 188 | newIt = items[newStart]; 189 | newRef = imap.get(newIt); 190 | if (newRef == null) { 191 | newRef = createRef(template(newIt), env); 192 | imap.set(newIt, newRef); 193 | } else { 194 | oldItems[newRef.index] = null; 195 | } 196 | refs[newStart] = newRef; 197 | env.parent.insertBefore(newRef.node, oldRefs[oldStart].node); 198 | newStart++; 199 | } 200 | while (oldStart <= oldEnd) { 201 | oldIt = oldItems[oldStart]; 202 | if (oldIt != null) { 203 | oldRef = oldRefs[oldStart]; 204 | env.parent.removeChild(oldRef.node); 205 | imap.delete(oldIt); 206 | oldRef.dispose(); 207 | } 208 | oldStart++; 209 | } 210 | while (newStart <= newEnd) { 211 | newIt = items[newStart]; 212 | const ref = createRef(template(newIt), env); 213 | imap.set(newIt, ref); 214 | refs[newStart] = ref; 215 | env.parent.insertBefore( 216 | ref.node, 217 | oldStart < oldRefs.length ? oldRefs[oldStart].node : nextSibling 218 | ); 219 | newStart++; 220 | } 221 | refs.forEach((ref, index) => { 222 | ref.index = index; 223 | }); 224 | } 225 | }); 226 | } 227 | 228 | export function dynamic(getDirective) { 229 | return directive(function dynamicDirective(env) { 230 | let ref; 231 | const disposer = env.subscribe(syncChild); 232 | env.onDispose(() => { 233 | disposer(); 234 | if (ref != null) ref.dispose(); 235 | }); 236 | function syncChild() { 237 | const oldRef = ref; 238 | ref = createRef(getDirective(env.ctx), env); 239 | if (oldRef == null) { 240 | env.parent.appendChild(ref.node); 241 | } else if (oldRef != null) { 242 | oldRef.dispose(); 243 | env.parent.replaceChild(ref.node, oldRef.node); 244 | } 245 | } 246 | }); 247 | } 248 | 249 | function createRef(dom, env) { 250 | let ref = { 251 | _disposers: [], 252 | appendChild(node) { 253 | ref.node = node; 254 | }, 255 | onDispose(d) { 256 | ref._disposers.push(d); 257 | }, 258 | dispose() { 259 | ref._disposers.forEach(d => d()); 260 | } 261 | }; 262 | dom({ ...env, parent: ref, onDispose: ref.onDispose }); 263 | return ref; 264 | } 265 | 266 | export function provider(newCtx, ...children) { 267 | return directive(function contextProviderDirective(env) { 268 | runChild(children, { ...env, ctx: { ...env.ctx, ...newCtx } }); 269 | }); 270 | } 271 | 272 | export function render(dom, parent, ctx = {}) { 273 | let ref = createRef(dom, { 274 | subscribe: autorun, 275 | ctx 276 | }); 277 | parent.textContent = ""; 278 | parent.appendChild(ref.node); 279 | return ref; 280 | } 281 | 282 | export function createDirProxy(dirFn) { 283 | const factoryMap = new Map(); 284 | return new Proxy( 285 | {}, 286 | { 287 | get(target, key) { 288 | let boundDir = factoryMap.get(key); 289 | if (boundDir == null) { 290 | boundDir = dirFn.bind(null, key); 291 | factoryMap.set(key, boundDir); 292 | } 293 | return boundDir; 294 | } 295 | } 296 | ); 297 | } 298 | 299 | export const h = createDirProxy(el); 300 | export const p = createDirProxy(prop); 301 | export const a = createDirProxy(attr); 302 | export const style = createDirProxy(styleKey); 303 | export const on = createDirProxy(event); 304 | --------------------------------------------------------------------------------