├── .gitignore
├── .npmignore
├── .prettierignore
├── .travis.yml
├── LICENSE
├── README.md
├── __tests__
├── h3.js
├── router.js
├── store.js
└── vnode.js
├── babel.config.js
├── description
├── docs
├── CNAME
├── H3_DeveloperGuide.htm
├── H3_DeveloperGuide.md
├── css
│ ├── mini-default.css
│ ├── prism.css
│ └── style.css
├── example
│ ├── assets
│ │ ├── css
│ │ │ └── style.css
│ │ └── js
│ │ │ ├── app.js
│ │ │ ├── components
│ │ │ ├── AddTodoForm.js
│ │ │ ├── EmptyTodoError.js
│ │ │ ├── MainView.js
│ │ │ ├── NavigationBar.js
│ │ │ ├── Paginator.js
│ │ │ ├── SettingsView.js
│ │ │ ├── Todo.js
│ │ │ └── TodoList.js
│ │ │ ├── h3.js
│ │ │ └── modules.js
│ └── index.html
├── favicon.png
├── images
│ ├── h3.sequence.svg
│ └── h3.svg
├── index.html
├── js
│ ├── app.js
│ ├── h3.js
│ └── vendor
│ │ ├── marked.js
│ │ └── prism.js
└── md
│ ├── about.md
│ ├── api.md
│ ├── best-practices.md
│ ├── key-concepts.md
│ ├── overview.md
│ ├── quick-start.md
│ └── tutorial.md
├── h3.js
├── h3.js.map
├── h3.min.js
├── jest.config.js
├── package-lock.json
├── package.json
└── scripts
├── release.js
└── test-setup.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | *.tgz
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | docs/H3_DeveloperGuide.*
2 | *.tgz
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | *.md
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 | node_js:
4 | - '12'
5 | branches:
6 | only:
7 | - master
8 | cache:
9 | directories:
10 | - node_modules
11 | before_install:
12 | - npm update
13 | install:
14 | - npm install
15 | script:
16 | - npm run coveralls
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Fabio Cevasco
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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | ***
7 |
8 | ## Overview
9 |
10 | **H3** is a microframework to build client-side single-page applications (SPAs) in modern JavaScript.
11 |
12 | H3 is also:
13 |
14 | - **tiny**, less than 4KB minified and gzipped.
15 | - **modern**, in the sense that it runs only in modern browsers (latest versions of Chrome, Firefox, Edge & similar).
16 | - **easy** to learn, its API is comprised of only seven methods and two properties.
17 |
18 | ### I'm sold! Where can I get it?
19 |
20 | Here, look, it's just one file:
21 |
22 | Download v0.11.0 (Keen Klingon)
23 |
24 | Or get the minified version here.
25 |
26 | Yes there is also a [NPM package](https://www.npmjs.com/package/@h3rald/h3) if you want to use it with WebPack and similar, but let me repeat: _it's just one file_.
27 |
28 | ### Hello, World?
29 |
30 | Here's an example of an extremely minimal SPA created with H3:
31 |
32 | ```js
33 | import { h3, h } from "./h3.js";
34 | h3.init(() => h("h1", "Hello, World!"));
35 | ```
36 |
37 | This will render a `h1` tag within the document body, containing the text `"Hello, World!"`.
38 |
39 | ### Something more complex?
40 |
41 | Have a look at the code of a [simple todo list](https://github.com/h3rald/h3/tree/master/docs/example) ([demo](https://h3.js.org/example/index.html)) with several components, a store and some routing.
42 |
43 | ### No, I meant a real web application...
44 |
45 | OK, have a look at [litepad.h3rald.com](https://litepad.h3rald.com) — it's a powerful notepad application that demonstrates how to create custom controls, route components, forms, and integrate third-party tools. The code is of course [on GitHub](https://github.com/h3rald/litepad).
46 |
47 | ### Can I use it then, no strings attached?
48 |
49 | Yes. It's [MIT-licensed](https://github.com/h3rald/h3/blob/master/LICENSE).
50 |
51 | ### What if something is broken?
52 |
53 | Go fix it! Or at least open an issue on the [Github repo](https://github.com/h3rald/h3), pleasy.
54 |
55 | ### Can I download a copy of all the documentation as a standalone HTML file?
56 |
57 | What a weird thing to ask... sure you can: [here](https://h3.js.org/H3_DeveloperGuide.htm)!
58 |
--------------------------------------------------------------------------------
/__tests__/h3.js:
--------------------------------------------------------------------------------
1 | const mod = require("../h3.js");
2 | const h3 = mod.h3;
3 | const h = mod.h;
4 |
5 | describe("h3", () => {
6 | beforeEach(() => {
7 | jest
8 | .spyOn(window, "requestAnimationFrame")
9 | .mockImplementation((cb) => cb());
10 | });
11 |
12 | afterEach(() => {
13 | window.requestAnimationFrame.mockRestore();
14 | });
15 |
16 | it("should support a way to discriminate functions and objects", () => {
17 | const v1 = h("div", { onclick: () => true });
18 | const v2 = h("div", { onclick: () => true });
19 | const v3 = h("div", { onclick: () => false });
20 | const v4 = h("div");
21 | expect(v1.equal(v2)).toEqual(true);
22 | expect(v1.equal(v3)).toEqual(false);
23 | expect(v4.equal({ type: "div" })).toEqual(false);
24 | expect(v1.equal(null, null)).toEqual(true);
25 | expect(v1.equal(null, undefined)).toEqual(false);
26 | });
27 |
28 | it("should support the creation of empty virtual node elements", () => {
29 | expect(h("div")).toEqual({
30 | type: "div",
31 | children: [],
32 | props: {},
33 | classList: [],
34 | data: {},
35 | eventListeners: {},
36 | id: undefined,
37 | $html: undefined,
38 | style: undefined,
39 | value: undefined,
40 | });
41 | });
42 |
43 | it("should throw an error when invalid arguments are supplied", () => {
44 | const empty = () => h();
45 | const invalid1st = () => h(1);
46 | const invalid1st2 = () => h(1, {});
47 | const invalid1st3 = () => h(1, {}, []);
48 | const invalid1st1 = () => h(() => ({ type: "#text", value: "test" }));
49 | const invalid1st1b = () => h({ a: 2 });
50 | const invalid2nd = () => h("div", 1);
51 | const invalid2nd2 = () => h("div", true, []);
52 | const invalid2nd3 = () => h("div", null, []);
53 | const invalidChildren = () => h("div", ["test", 1, 2]);
54 | const emptySelector = () => h("");
55 | expect(empty).toThrowError(/No arguments passed/);
56 | expect(invalid1st).toThrowError(/Invalid first argument/);
57 | expect(invalid1st2).toThrowError(/Invalid first argument/);
58 | expect(invalid1st3).toThrowError(/Invalid first argument/);
59 | expect(invalid1st1).toThrowError(/does not return a VNode/);
60 | expect(invalid1st1b).toThrowError(/Invalid first argument/);
61 | expect(invalid2nd).toThrowError(/second argument of a VNode constructor/);
62 | expect(invalid2nd2).toThrowError(/Invalid second argument/);
63 | expect(invalid2nd3).toThrowError(/Invalid second argument/);
64 | expect(invalidChildren).toThrowError(/not a VNode: 1/);
65 | expect(emptySelector).toThrowError(/Invalid selector/);
66 | });
67 |
68 | it("should support several child arguments", () => {
69 | let vnode = h("div", { test: "a" }, "a", "b", "c");
70 | expect(vnode.children.length).toEqual(3);
71 | vnode = h("div", "a", "b", "c");
72 | expect(vnode.children.length).toEqual(3);
73 | vnode = h("div", "a", "b");
74 | expect(vnode.children.length).toEqual(2);
75 | });
76 |
77 | it("should support the creation of elements with a single, non-array child", () => {
78 | const vnode1 = h("div", () => "test");
79 | const vnode2 = h("div", () => h("span"));
80 | expect(vnode1.children[0].value).toEqual("test");
81 | expect(vnode2.children[0].type).toEqual("span");
82 | });
83 |
84 | it("should remove null/false/undefined children", () => {
85 | const v1 = h("div", [false, "test", undefined, null, ""]);
86 | expect(v1.children).toEqual([
87 | h({ type: "#text", value: "test" }),
88 | h({ type: "#text", value: "" }),
89 | ]);
90 | });
91 |
92 | it("should support the creation of nodes with a single child node", () => {
93 | const result = {
94 | type: "div",
95 | children: [
96 | {
97 | type: "#text",
98 | children: [],
99 | props: {},
100 | classList: [],
101 | data: {},
102 | eventListeners: {},
103 | id: undefined,
104 | $html: undefined,
105 | style: undefined,
106 | value: "test",
107 | },
108 | ],
109 | props: {},
110 | classList: [],
111 | data: {},
112 | eventListeners: {},
113 | id: undefined,
114 | $html: undefined,
115 | style: undefined,
116 | value: undefined,
117 | };
118 | expect(h("div", "test")).toEqual(result);
119 | const failing = () => h("***");
120 | expect(failing).toThrowError(/Invalid selector/);
121 | });
122 |
123 | it("should support the creation of virtual node elements with classes", () => {
124 | const a = h("div.a.b.c");
125 | const b = h("div", { classList: ["a", "b", "c"] });
126 | expect(a).toEqual({
127 | type: "div",
128 | children: [],
129 | props: {},
130 | classList: ["a", "b", "c"],
131 | data: {},
132 | eventListeners: {},
133 | id: undefined,
134 | $html: undefined,
135 | style: undefined,
136 | type: "div",
137 | value: undefined,
138 | });
139 | expect(a).toEqual(b);
140 | });
141 |
142 | it("should support the creation of virtual node elements with props and classes", () => {
143 | expect(h("div.test1.test2", { id: "test" })).toEqual({
144 | type: "div",
145 | children: [],
146 | classList: ["test1", "test2"],
147 | data: {},
148 | props: {},
149 | eventListeners: {},
150 | id: "test",
151 | $html: undefined,
152 | style: undefined,
153 | type: "div",
154 | value: undefined,
155 | });
156 | });
157 |
158 | it("should support the creation of virtual node elements with text children and classes", () => {
159 | expect(h("div.test", ["a", "b"])).toEqual({
160 | type: "div",
161 | children: [
162 | {
163 | props: {},
164 | children: [],
165 | classList: [],
166 | data: {},
167 | eventListeners: {},
168 | id: undefined,
169 | $html: undefined,
170 | style: undefined,
171 | type: "#text",
172 | value: "a",
173 | },
174 | {
175 | props: {},
176 | children: [],
177 | classList: [],
178 | data: {},
179 | eventListeners: {},
180 | id: undefined,
181 | $html: undefined,
182 | style: undefined,
183 | type: "#text",
184 | value: "b",
185 | },
186 | ],
187 | props: {},
188 | classList: ["test"],
189 | data: {},
190 | eventListeners: {},
191 | id: undefined,
192 | $html: undefined,
193 | style: undefined,
194 | value: undefined,
195 | });
196 | });
197 |
198 | it("should support the creation of virtual node elements with text children, props, and classes", () => {
199 | expect(h("div.test", { title: "Test...", id: "test" }, ["a", "b"])).toEqual(
200 | {
201 | type: "div",
202 | children: [
203 | {
204 | props: {},
205 | children: [],
206 | classList: [],
207 | data: {},
208 | eventListeners: {},
209 | id: undefined,
210 | $html: undefined,
211 | style: undefined,
212 | type: "#text",
213 | value: "a",
214 | },
215 | {
216 | props: {},
217 | children: [],
218 | classList: [],
219 | data: {},
220 | eventListeners: {},
221 | id: undefined,
222 | $html: undefined,
223 | style: undefined,
224 | type: "#text",
225 | value: "b",
226 | },
227 | ],
228 | data: {},
229 | eventListeners: {},
230 | id: "test",
231 | $html: undefined,
232 | style: undefined,
233 | value: undefined,
234 | props: { title: "Test..." },
235 | classList: ["test"],
236 | }
237 | );
238 | });
239 |
240 | it("should support the creation of virtual node elements with props", () => {
241 | expect(h("input", { type: "text", value: "AAA" })).toEqual({
242 | type: "input",
243 | children: [],
244 | data: {},
245 | eventListeners: {},
246 | id: undefined,
247 | $html: undefined,
248 | style: undefined,
249 | value: "AAA",
250 | props: { type: "text" },
251 | classList: [],
252 | });
253 | });
254 |
255 | it("should support the creation of virtual node elements with event handlers", () => {
256 | const fn = () => true;
257 | expect(h("button", { onclick: fn })).toEqual({
258 | type: "button",
259 | children: [],
260 | data: {},
261 | eventListeners: {
262 | click: fn,
263 | },
264 | id: undefined,
265 | $html: undefined,
266 | style: undefined,
267 | value: undefined,
268 | props: {},
269 | classList: [],
270 | });
271 | expect(() => h("span", { onclick: "something" })).toThrowError(
272 | /onclick event is not a function/
273 | );
274 | });
275 |
276 | it("should support the creation of virtual node elements with element children and classes", () => {
277 | expect(
278 | h("div.test", ["a", h("span", ["test1"]), () => h("span", ["test2"])])
279 | ).toEqual({
280 | props: {},
281 | type: "div",
282 | children: [
283 | {
284 | props: {},
285 | children: [],
286 | classList: [],
287 | data: {},
288 | eventListeners: {},
289 | id: undefined,
290 | $html: undefined,
291 | style: undefined,
292 | type: "#text",
293 | value: "a",
294 | },
295 | {
296 | type: "span",
297 | children: [
298 | {
299 | props: {},
300 | children: [],
301 | classList: [],
302 | data: {},
303 | eventListeners: {},
304 | id: undefined,
305 | $html: undefined,
306 | style: undefined,
307 | type: "#text",
308 | value: "test1",
309 | },
310 | ],
311 | props: {},
312 | classList: [],
313 | data: {},
314 | eventListeners: {},
315 | id: undefined,
316 | $html: undefined,
317 | style: undefined,
318 | value: undefined,
319 | },
320 | {
321 | type: "span",
322 | children: [
323 | {
324 | props: {},
325 | children: [],
326 | classList: [],
327 | data: {},
328 | eventListeners: {},
329 | id: undefined,
330 | $html: undefined,
331 | style: undefined,
332 | type: "#text",
333 | value: "test2",
334 | },
335 | ],
336 | props: {},
337 | classList: [],
338 | data: {},
339 | eventListeners: {},
340 | id: undefined,
341 | $html: undefined,
342 | style: undefined,
343 | value: undefined,
344 | },
345 | ],
346 | classList: ["test"],
347 | data: {},
348 | eventListeners: {},
349 | id: undefined,
350 | $html: undefined,
351 | style: undefined,
352 | value: undefined,
353 | });
354 | });
355 |
356 | it("should not allow certain methods and properties to be called/accessed before initialization", () => {
357 | const route = () => h3.route;
358 | const state = () => h3.state;
359 | const redraw = () => h3.redraw();
360 | const dispatch = () => h3.dispatch();
361 | const on = () => h3.on();
362 | const navigateTo = () => h3.navigateTo();
363 | expect(route).toThrowError(/No application initialized/);
364 | expect(state).toThrowError(/No application initialized/);
365 | expect(redraw).toThrowError(/No application initialized/);
366 | expect(dispatch).toThrowError(/No application initialized/);
367 | expect(on).toThrowError(/No application initialized/);
368 | expect(navigateTo).toThrowError(/No application initialized/);
369 | });
370 |
371 | it("should provide an init method to initialize a SPA with a single component", async () => {
372 | const c = () => h("div", "Hello, World!");
373 | const body = document.body;
374 | const appendChild = jest.spyOn(body, "appendChild");
375 | await h3.init(c);
376 | expect(appendChild).toHaveBeenCalled();
377 | expect(body.childNodes[0].childNodes[0].data).toEqual("Hello, World!");
378 | });
379 |
380 | it("should provide some validation at initialization time", async () => {
381 | try {
382 | await h3.init({ element: "INVALID", routes: {} });
383 | } catch (e) {
384 | expect(e.message).toMatch(/Invalid element/);
385 | }
386 | try {
387 | await h3.init({ element: document.body });
388 | } catch (e) {
389 | expect(e.message).toMatch(/not a valid configuration object/);
390 | }
391 | try {
392 | await h3.init({ element: document.body, routes: {} });
393 | } catch (e) {
394 | expect(e.message).toMatch(/No routes/);
395 | }
396 | });
397 |
398 | it("should expose a redraw method", async () => {
399 | const vnode = h("div");
400 | await h3.init(() => vnode);
401 | jest.spyOn(vnode, "redraw");
402 | h3.redraw();
403 | expect(vnode.redraw).toHaveBeenCalled();
404 | h3.redraw(true);
405 | h3.redraw();
406 | h3.redraw();
407 | expect(vnode.redraw).toHaveBeenCalledTimes(2);
408 | });
409 |
410 | it("should not redraw while a other redraw is in progress", async () => {
411 | const vnode = h("div");
412 | await h3.init({
413 | routes: {
414 | "/": () => vnode,
415 | },
416 | });
417 | jest.spyOn(vnode, "redraw");
418 | h3.redraw(true);
419 | h3.redraw();
420 | expect(vnode.redraw).toHaveBeenCalledTimes(1);
421 | });
422 |
423 | it("should expose a screen method to define screen-level components with (optional) setup and teardown", async () => {
424 | expect(() => h3.screen({})).toThrowError(/No display property specified/);
425 | expect(() => h3.screen({ setup: 1, display: () => "" })).toThrowError(
426 | /setup property is not a function/
427 | );
428 | expect(() => h3.screen({ teardown: 1, display: () => "" })).toThrowError(
429 | /teardown property is not a function/
430 | );
431 | let s = h3.screen({ display: () => "test" });
432 | expect(typeof s).toEqual("function");
433 | s = h3.screen({
434 | display: () => "test",
435 | setup: (state) => state,
436 | teardown: (state) => state,
437 | });
438 | expect(typeof s.setup).toEqual("function");
439 | expect(typeof s.teardown).toEqual("function");
440 | });
441 | });
442 |
--------------------------------------------------------------------------------
/__tests__/router.js:
--------------------------------------------------------------------------------
1 | const mod = require('../h3.js');
2 | const h3 = mod.h3;
3 | const h = mod.h;
4 |
5 | let preStartCalled = false;
6 | let postStartCalled = false;
7 | let count = 0;
8 | let result = 0;
9 |
10 | const setCount = () => {
11 | count = count + 2;
12 | h3.dispatch('count/set', count);
13 | };
14 | let hash = '#/c2';
15 | const mockLocation = {
16 | get hash() {
17 | return hash;
18 | },
19 | set hash(value) {
20 | const event = new CustomEvent('hashchange');
21 | event.oldURL = hash;
22 | event.newURL = value;
23 | hash = value;
24 | window.dispatchEvent(event);
25 | },
26 | };
27 | const C1 = () => {
28 | const parts = h3.route.parts;
29 | const content = Object.keys(parts).map((key) => h('li', `${key}: ${parts[key]}`));
30 | return h('ul.c1', content);
31 | };
32 |
33 | const C2 = () => {
34 | const params = h3.route.params;
35 | const content = Object.keys(params).map((key) => h('li', `${key}: ${params[key]}`));
36 | return h('ul.c2', content);
37 | };
38 |
39 | describe('h3 (Router)', () => {
40 | beforeEach(async () => {
41 | const preStart = () => (preStartCalled = true);
42 | const postStart = () => (postStartCalled = true);
43 | await h3.init({
44 | routes: {
45 | '/c1/:a/:b/:c': C1,
46 | '/c2': C2,
47 | },
48 | location: mockLocation,
49 | preStart: preStart,
50 | postStart: postStart,
51 | });
52 | });
53 |
54 | it('should support routing configuration at startup', () => {
55 | expect(h3.route.def).toEqual('/c2');
56 | });
57 |
58 | it('should support pre/post start hooks', () => {
59 | expect(preStartCalled).toEqual(true);
60 | expect(postStartCalled).toEqual(true);
61 | });
62 |
63 | it('should support the capturing of parts within the current route', (done) => {
64 | const sub = h3.on('$redraw', () => {
65 | expect(document.body.childNodes[0].childNodes[1].textContent).toEqual('b: 2');
66 | sub();
67 | done();
68 | });
69 | mockLocation.hash = '#/c1/1/2/3';
70 | });
71 |
72 | it('should expose a navigateTo method to navigate to another path', (done) => {
73 | const sub = h3.on('$redraw', () => {
74 | expect(document.body.childNodes[0].childNodes[1].textContent).toEqual('test2: 2');
75 | sub();
76 | done();
77 | });
78 | h3.navigateTo('/c2', { test1: 1, test2: 2 });
79 | });
80 |
81 | it('should throw an error if no route matches', async () => {
82 | try {
83 | await h3.init({
84 | element: document.body,
85 | routes: {
86 | '/c1/:a/:b/:c': () => h('div'),
87 | '/c2': () => h('div'),
88 | },
89 | });
90 | } catch (e) {
91 | expect(e.message).toMatch(/No route matches/);
92 | }
93 | });
94 |
95 | it('should execute setup and teardown methods', (done) => {
96 | let redraws = 0;
97 | C1.setup = (cstate) => {
98 | cstate.result = cstate.result || 0;
99 | cstate.sub = h3.on('count/set', (state, count) => {
100 | cstate.result = count * count;
101 | });
102 | };
103 | C1.teardown = (cstate) => {
104 | cstate.sub();
105 | result = cstate.result;
106 | return { result: cstate.result };
107 | };
108 | const sub = h3.on('$redraw', () => {
109 | redraws++;
110 | setCount();
111 | setCount();
112 | if (redraws === 1) {
113 | expect(count).toEqual(4);
114 | expect(result).toEqual(0);
115 | h3.navigateTo('/c2');
116 | }
117 | if (redraws === 2) {
118 | expect(count).toEqual(8);
119 | expect(result).toEqual(16);
120 | delete C1.setup;
121 | delete C1.teardown;
122 | sub();
123 | done();
124 | }
125 | });
126 | h3.navigateTo('/c1/a/b/c');
127 | });
128 |
129 | it('should not navigate if setup method returns false', (done) => {
130 | let redraws = 0;
131 | const oldroute = h3.route;
132 | C1.setup = () => {
133 | return false;
134 | };
135 | h3.on('$navigation', (state, data) => {
136 | expect(data).toEqual(null);
137 | expect(h3.route).toEqual(oldroute);
138 | done();
139 | });
140 | h3.navigateTo('/c1/a/b/c');
141 | });
142 | });
143 |
--------------------------------------------------------------------------------
/__tests__/store.js:
--------------------------------------------------------------------------------
1 | const mod = require("../h3.js");
2 | const h3 = mod.h3;
3 | const h = mod.h;
4 |
5 | describe("h3 (Store)", () => {
6 | beforeEach(async () => {
7 | const test = () => {
8 | h3.on("$init", () => ({ online: true }));
9 | h3.on("$stop", () => ({ online: false }));
10 | h3.on("online/set", (state, data) => ({ online: data }));
11 | };
12 | return await h3.init({
13 | modules: [test],
14 | routes: { "/": () => h("div") },
15 | });
16 | });
17 |
18 | afterEach(() => {
19 | h3.dispatch("$stop");
20 | });
21 |
22 | it("should expose a method to retrieve the application state", () => {
23 | expect(h3.state.online).toEqual(true);
24 | });
25 |
26 | it("should expose a method to dispatch messages", () => {
27 | expect(h3.state.online).toEqual(true);
28 | h3.dispatch("online/set", "YEAH!");
29 | expect(h3.state.online).toEqual("YEAH!");
30 | });
31 |
32 | it("should expose a method to subscribe to messages (and also cancel subscriptions)", () => {
33 | const sub = h3.on("online/clear", () => ({ online: undefined }));
34 | h3.dispatch("online/clear");
35 | expect(h3.state.online).toEqual(undefined);
36 | h3.dispatch("online/set", "reset");
37 | expect(h3.state.online).toEqual("reset");
38 | sub();
39 | h3.dispatch("online/clear");
40 | expect(h3.state.online).toEqual("reset");
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/__tests__/vnode.js:
--------------------------------------------------------------------------------
1 | const mod = require('../h3.js');
2 | const h3 = mod.h3;
3 | const h = mod.h;
4 |
5 | describe('VNode', () => {
6 | it('should provide a from method to initialize itself from an object', () => {
7 | const fn = () => true;
8 | const obj = {
9 | id: 'test',
10 | type: 'input',
11 | value: 'AAA',
12 | $html: '',
13 | data: { a: '1', b: '2' },
14 | eventListeners: { click: fn },
15 | children: [],
16 | props: { title: 'test' },
17 | classList: ['a1', 'a2'],
18 | style: 'padding: 2px',
19 | };
20 | const vnode1 = h('br');
21 | vnode1.from(obj);
22 | const vnode2 = h('input#test.a1.a2', {
23 | value: 'AAA',
24 | $html: '',
25 | data: { a: '1', b: '2' },
26 | onclick: fn,
27 | title: 'test',
28 | style: 'padding: 2px',
29 | });
30 | expect(vnode1).toEqual(vnode2);
31 | });
32 |
33 | it('should provide a render method able to render textual nodes', () => {
34 | const createTextNode = jest.spyOn(document, 'createTextNode');
35 | const vnode = h({ type: '#text', value: 'test' });
36 | const node = vnode.render();
37 | expect(createTextNode).toHaveBeenCalledWith('test');
38 | expect(node.constructor).toEqual(Text);
39 | });
40 |
41 | it('should provide a render method able to render simple element nodes', () => {
42 | const createElement = jest.spyOn(document, 'createElement');
43 | const vnode = h('br');
44 | const node = vnode.render();
45 | expect(createElement).toHaveBeenCalledWith('br');
46 | expect(node.constructor).toEqual(HTMLBRElement);
47 | });
48 |
49 | it('should provide a render method able to render element nodes with props and classes', () => {
50 | const createElement = jest.spyOn(document, 'createElement');
51 | const vnode = h('span.test1.test2', { title: 'test', falsy: false });
52 | const node = vnode.render();
53 | expect(createElement).toHaveBeenCalledWith('span');
54 | expect(node.constructor).toEqual(HTMLSpanElement);
55 | expect(node.getAttribute('title')).toEqual('test');
56 | expect(node.classList.value).toEqual('test1 test2');
57 | });
58 |
59 | it('should provide a render method able to render element nodes with children', () => {
60 | const vnode = h('ul', [h('li', 'test1'), h('li', 'test2')]);
61 | const createElement = jest.spyOn(document, 'createElement');
62 | const node = vnode.render();
63 | expect(createElement).toHaveBeenCalledWith('ul');
64 | expect(createElement).toHaveBeenCalledWith('li');
65 | expect(node.constructor).toEqual(HTMLUListElement);
66 | expect(node.childNodes.length).toEqual(2);
67 | expect(node.childNodes[1].constructor).toEqual(HTMLLIElement);
68 | expect(node.childNodes[0].childNodes[0].data).toEqual('test1');
69 | });
70 |
71 | it('should handle boolean props when redrawing', () => {
72 | const vnode1 = h('input', { type: 'checkbox', checked: true });
73 | const node = vnode1.render();
74 | expect(node.checked).toEqual(true);
75 | const vnode = h('input', { type: 'checkbox', checked: false });
76 | vnode1.redraw({ node, vnode });
77 | expect(node.checked).toEqual(false);
78 | });
79 |
80 | it('should handle falsy props when redrawing', () => {
81 | const vnode1 = h('test-element', { q: 1 });
82 | const node = vnode1.render();
83 | expect(node.q).toEqual(1);
84 | const vnode = h('test-element', { q: 0 });
85 | vnode1.redraw({ node, vnode });
86 | expect(node.q).toEqual(0);
87 | expect(vnode1.props.q).toEqual(0);
88 | });
89 |
90 | it('should handle non-string props as properties and not create attributes', () => {
91 | const v = h('div', {
92 | test: true,
93 | obj: { a: 1, b: 2 },
94 | arr: [1, 2, 3],
95 | num: 2.3,
96 | str: 'test',
97 | s: '',
98 | title: 'testing!',
99 | value: false,
100 | });
101 | const v2 = h('div', {
102 | test: true,
103 | obj: { a: 1, b: 2 },
104 | arr: [1, 2, 3],
105 | s: '',
106 | title: 'testing!',
107 | value: 'true',
108 | });
109 | const n = v.render();
110 | expect(n.test).toEqual(true);
111 | expect(n.obj).toEqual({ a: 1, b: 2 });
112 | expect(n.arr).toEqual([1, 2, 3]);
113 | expect(n.num).toEqual(2.3);
114 | expect(n.str).toEqual('test');
115 | expect(n.getAttribute('str')).toEqual('test');
116 | expect(n.s).toEqual('');
117 | expect(n.getAttribute('s')).toEqual('');
118 | expect(n.title).toEqual('testing!');
119 | expect(n.getAttribute('title')).toEqual('testing!');
120 | expect(n.value).toEqual(undefined);
121 | expect(n.getAttribute('value')).toEqual(null);
122 | v.redraw({ node: n, vnode: v2 });
123 | expect(n.getAttribute('value')).toEqual('true');
124 | v2.value = null;
125 | v.redraw({ node: n, vnode: v2 });
126 | expect(n.getAttribute('value')).toEqual('');
127 | });
128 |
129 | it('should provide a render method able to render element nodes with a value', () => {
130 | let vnode = h('input', { value: 'test' });
131 | const createElement = jest.spyOn(document, 'createElement');
132 | let node = vnode.render();
133 | expect(createElement).toHaveBeenCalledWith('input');
134 | expect(node.constructor).toEqual(HTMLInputElement);
135 | expect(node.value).toEqual('test');
136 | vnode = h('input', { value: null });
137 | node = vnode.render();
138 | expect(node.value).toEqual('');
139 | vnode = h('test', { value: 123 });
140 | node = vnode.render();
141 | expect(node.getAttribute('value')).toEqual('123');
142 | expect(node.value).toEqual(undefined);
143 | });
144 |
145 | it('should provide a render method able to render element nodes with event handlers', () => {
146 | const handler = () => {
147 | console.log('test');
148 | };
149 | const vnode = h('button', { onclick: handler });
150 | const button = document.createElement('button');
151 | const createElement = jest.spyOn(document, 'createElement').mockImplementationOnce(() => {
152 | return button;
153 | });
154 | const addEventListener = jest.spyOn(button, 'addEventListener');
155 | const node = vnode.render();
156 | expect(createElement).toHaveBeenCalledWith('button');
157 | expect(node.constructor).toEqual(HTMLButtonElement);
158 | expect(addEventListener).toHaveBeenCalledWith('click', handler);
159 | });
160 |
161 | it('it should provide a render method able to render elements with special props', () => {
162 | const vnode = h('div', {
163 | id: 'test',
164 | style: 'margin: auto;',
165 | data: { test: 'aaa' },
166 | $html: '
Hello!
', 167 | }); 168 | const createElement = jest.spyOn(document, 'createElement'); 169 | const node = vnode.render(); 170 | expect(createElement).toHaveBeenCalledWith('div'); 171 | expect(node.constructor).toEqual(HTMLDivElement); 172 | expect(node.style.cssText).toEqual('margin: auto;'); 173 | expect(node.id).toEqual('test'); 174 | expect(node.dataset['test']).toEqual('aaa'); 175 | expect(node.childNodes[0].textContent).toEqual('Hello!'); 176 | }); 177 | 178 | it('should provide a redraw method that is able to add new DOM nodes', () => { 179 | const oldvnode = h('div#test', h('span')); 180 | const newvnodeNoChildren = h('div'); 181 | const newvnode = h('div', [h('span#a'), h('span')]); 182 | const node = oldvnode.render(); 183 | const span = node.childNodes[0]; 184 | oldvnode.redraw({ node: node, vnode: newvnodeNoChildren }); 185 | expect(oldvnode.children.length).toEqual(0); 186 | oldvnode.redraw({ node: node, vnode: newvnode }); 187 | expect(oldvnode).toEqual(newvnode); 188 | expect(oldvnode.children.length).toEqual(2); 189 | expect(node.childNodes.length).toEqual(2); 190 | expect(node.childNodes[0].id).toEqual('a'); 191 | expect(span).toEqual(node.childNodes[1]); 192 | }); 193 | 194 | it('should provide a redraw method that is able to remove existing DOM nodes', () => { 195 | let oldvnode = h('div', [h('span#a'), h('span')]); 196 | let newvnode = h('div', [h('span')]); 197 | let node = oldvnode.render(); 198 | oldvnode.redraw({ node: node, vnode: newvnode }); 199 | expect(oldvnode).toEqual(newvnode); 200 | expect(oldvnode.children.length).toEqual(1); 201 | expect(node.childNodes.length).toEqual(1); 202 | oldvnode = h('div.test-children', [h('span.a'), h('span.b')]); 203 | node = oldvnode.render(); 204 | newvnode = h('div.test-children', [h('div.c')]); 205 | oldvnode.redraw({ node: node, vnode: newvnode }); 206 | expect(oldvnode).toEqual(newvnode); 207 | expect(oldvnode.children.length).toEqual(1); 208 | expect(node.childNodes.length).toEqual(1); 209 | expect(oldvnode.children[0].classList[0]).toEqual('c'); 210 | }); 211 | 212 | it('should provide a redraw method that is able to figure out differences in children', () => { 213 | const oldvnode = h('div', [h('span', 'a'), h('span'), h('span', 'b')]); 214 | const newvnode = h('div', [h('span', 'a'), h('span', 'c'), h('span', 'b')]); 215 | const node = oldvnode.render(); 216 | oldvnode.redraw({ node: node, vnode: newvnode }); 217 | expect(node.childNodes[1].textContent).toEqual('c'); 218 | }); 219 | 220 | it('should provide a redraw method that is able to figure out differences in existing children', () => { 221 | const oldvnode = h('div', [h('span.test', 'a'), h('span.test', 'b'), h('span.test', 'c')]); 222 | const newvnode = h('div', [h('span.test', 'a'), h('span.test1', 'b'), h('span.test', 'c')]); 223 | const node = oldvnode.render(); 224 | oldvnode.redraw({ node: node, vnode: newvnode }); 225 | expect(node.childNodes[0].classList[0]).toEqual('test'); 226 | expect(node.childNodes[1].classList[0]).toEqual('test1'); 227 | expect(node.childNodes[2].classList[0]).toEqual('test'); 228 | }); 229 | 230 | it('should provide a redraw method that is able to update different props', () => { 231 | const oldvnode = h('span', { title: 'a', something: 'b' }); 232 | const newvnode = h('span', { title: 'b', id: 'bbb' }); 233 | const node = oldvnode.render(); 234 | oldvnode.redraw({ node: node, vnode: newvnode }); 235 | expect(oldvnode).toEqual(newvnode); 236 | expect(node.getAttribute('title')).toEqual('b'); 237 | expect(node.getAttribute('id')).toEqual('bbb'); 238 | expect(node.hasAttribute('something')).toEqual(false); 239 | }); 240 | 241 | it('should provide a redraw method that is able to update different classes', () => { 242 | const oldvnode = h('span.a.b', { title: 'b' }); 243 | const newvnode = h('span.a.c', { title: 'b' }); 244 | const node = oldvnode.render(); 245 | oldvnode.redraw({ node: node, vnode: newvnode }); 246 | expect(oldvnode).toEqual(newvnode); 247 | expect(node.classList.value).toEqual('a c'); 248 | }); 249 | 250 | it('should provide redraw method to detect changed nodes if they have different elements', () => { 251 | const oldvnode = h('span.c', { title: 'b' }); 252 | const newvnode = h('div.c', { title: 'b' }); 253 | const container = document.createElement('div'); 254 | const node = oldvnode.render(); 255 | container.appendChild(node); 256 | oldvnode.redraw({ node: node, vnode: newvnode }); 257 | expect(node).not.toEqual(container.childNodes[0]); 258 | expect(node.constructor).toEqual(HTMLSpanElement); 259 | expect(container.childNodes[0].constructor).toEqual(HTMLDivElement); 260 | }); 261 | 262 | it('should provide redraw method to detect position changes in child nodes', () => { 263 | const v1 = h('ul', [h('li.a'), h('li.b'), h('li.c'), h('li.d')]); 264 | const v2 = h('ul', [h('li.c'), h('li.b'), h('li.a'), h('li.d')]); 265 | const n = v1.render(); 266 | expect(n.childNodes[0].classList[0]).toEqual('a'); 267 | v1.redraw({ node: n, vnode: v2 }); 268 | expect(n.childNodes[0].classList[0]).toEqual('c'); 269 | }); 270 | 271 | it('should optimize insertion and deletions when redrawing if all old/new children exist', () => { 272 | const v = h('div', h('a'), h('d')); 273 | const vnode = h('div', h('a'), h('b'), h('c'), h('d')); 274 | const node = v.render(); 275 | v.redraw({ node, vnode }); 276 | expect(v.children.length).toEqual(4); 277 | }); 278 | 279 | it('should provide redraw method to detect changed nodes if they have different node types', () => { 280 | const oldvnode = h('span.c', { title: 'b' }); 281 | const newvnode = h({ type: '#text', value: 'test' }); 282 | const container = document.createElement('div'); 283 | const node = oldvnode.render(); 284 | container.appendChild(node); 285 | expect(node.constructor).toEqual(HTMLSpanElement); 286 | oldvnode.redraw({ node: node, vnode: newvnode }); 287 | expect(node).not.toEqual(container.childNodes[0]); 288 | expect(container.childNodes[0].data).toEqual('test'); 289 | }); 290 | 291 | it('should provide redraw method to detect changed nodes if they have different text', () => { 292 | const oldvnode = h({ type: '#text', value: 'test1' }); 293 | const newvnode = h({ type: '#text', value: 'test2' }); 294 | const container = document.createElement('div'); 295 | const node = oldvnode.render(); 296 | container.appendChild(node); 297 | expect(node.data).toEqual('test1'); 298 | oldvnode.redraw({ node: node, vnode: newvnode }); 299 | expect(container.childNodes[0].data).toEqual('test2'); 300 | }); 301 | 302 | it('should provide redraw method to detect changed nodes and recurse', () => { 303 | const oldvnode = h('ul.c', { title: 'b' }, [h('li#aaa'), h('li#bbb'), h('li#ccc')]); 304 | const newvnode = h('ul.c', { title: 'b' }, [h('li#aaa'), h('li#ccc')]); 305 | const node = oldvnode.render(); 306 | oldvnode.redraw({ node: node, vnode: newvnode }); 307 | expect(oldvnode).toEqual(newvnode); 308 | expect(node.childNodes.length).toEqual(2); 309 | expect(node.childNodes[0].getAttribute('id')).toEqual('aaa'); 310 | expect(node.childNodes[1].getAttribute('id')).toEqual('ccc'); 311 | }); 312 | 313 | it('should provide a redraw method able to detect specific changes to style, data, value, props, $onrender and eventListeners', () => { 314 | const fn = () => false; 315 | const oldvnode = h('input', { 316 | style: 'margin: auto;', 317 | data: { a: 111, b: 222, d: 444 }, 318 | value: null, 319 | title: 'test', 320 | label: 'test', 321 | onkeydown: () => true, 322 | onclick: () => true, 323 | onkeypress: () => true, 324 | }); 325 | const newvnode = h('input', { 326 | style: false, 327 | data: { a: 111, b: 223, c: 333 }, 328 | title: 'test #2', 329 | label: 'test', 330 | something: false, 331 | somethingElse: { test: 1 }, 332 | value: 0, 333 | placeholder: 'test', 334 | onkeydown: () => true, 335 | onkeypress: () => false, 336 | $onrender: () => true, 337 | onhover: () => true, 338 | }); 339 | const newvnode2 = h('input', { 340 | style: false, 341 | data: { a: 111, b: 223, c: 333 }, 342 | title: 'test #2', 343 | label: 'test', 344 | something: false, 345 | somethingElse: { test: 1 }, 346 | placeholder: 'test', 347 | onkeydown: () => true, 348 | onkeypress: () => false, 349 | $onrender: () => true, 350 | onhover: () => true, 351 | }); 352 | const container = document.createElement('div'); 353 | const node = oldvnode.render(); 354 | expect(node.value).toEqual(''); 355 | container.appendChild(node); 356 | oldvnode.redraw({ node: node, vnode: newvnode }); 357 | expect(oldvnode).toEqual(newvnode); 358 | expect(node.style.cssText).toEqual(''); 359 | expect(node.dataset['a']).toEqual('111'); 360 | expect(node.dataset['c']).toEqual('333'); 361 | expect(node.dataset['b']).toEqual('223'); 362 | expect(node.dataset['d']).toEqual(undefined); 363 | expect(node.something).toEqual(false); 364 | expect(node.getAttribute('title')).toEqual('test #2'); 365 | expect(node.getAttribute('placeholder')).toEqual('test'); 366 | expect(node.value).toEqual('0'); 367 | oldvnode.redraw({ node, vnode: newvnode2 }); 368 | expect(node.value).toEqual(''); 369 | }); 370 | 371 | it('should handle value property/attribute for non-input fields', () => { 372 | const v = h('test', { value: null }); 373 | const n = v.render(); 374 | expect(n.value).toEqual(undefined); 375 | expect(n.getAttribute('value')).toEqual(null); 376 | }); 377 | 378 | it('should provide a redraw method able to detect changes in child content', () => { 379 | const v1 = h('ul', [h('li', 'a'), h('li', 'b')]); 380 | const n1 = v1.render(); 381 | const v2 = h('ul', { 382 | $html: '