├── .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 |
--------------------------------------------------------------------------------