├── .gitignore
├── README.md
├── biome.json
├── example
├── index.html
├── main.e2e.js
└── main.js
├── hiw.webp
├── package-lock.json
├── package.json
├── playwright.config.js
├── src
├── index.js
└── index.test.js
└── vite.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | docs/.vitepress/dist
27 | docs/.vitepress/dist
28 | coverage
29 | /test-results/
30 | /playwright-report/
31 | /blob-report/
32 | /playwright/.cache/
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## 📌 Nho
2 |
3 | Nho (`nhỏ` | `small` in `Vietnamese`) is a tiny library designed for easy Web Component development.
4 |
5 | ### Why Nho?
6 |
7 | - Writing a Web Component (WC) using vanilla JavaScript can be such tedious. Alternatively, popular WC libraries can be overkill and overweighted (4KB+) for creating small components like a `"Buy now" button` or a `cart listing`.
8 |
9 | - `Nho` simplifies the process by staying lightweight, removing unnecessary APIs, and using a simple DOM diffing algorithm.
10 |
11 | ### Features
12 |
13 | - `1.3KB` gzipped.
14 | - Simple API inspired from `Vue`.
15 |
16 |
17 | ### Example
18 | - [album list](https://nho-example.netlify.app/) - [source](./example)
19 |
20 | ### Limitation
21 |
22 | - In order to stay small, `Nho` skips few advanced features found in popular front-end frameworks like `key`, `Fragments`, `memo`. The DOM diffing algorithm is somewhat basic, but it is fast enough for small projects. If your components become too complex, consider other options.
23 |
24 | ### Installation
25 |
26 | #### using `npm`
27 | First, run
28 |
29 | ```
30 | npm install nho
31 | ```
32 |
33 | then
34 | ```js
35 | import { Nho } from 'nho';
36 | class MyCounterChild extends Nho {}
37 | ```
38 |
39 |
40 | #### using `CDN`
41 | First, add `script` to the `html` file
42 | ```html
43 |
44 | ```
45 |
46 | then, add `script` to the `html` file
47 |
48 | ```html
49 |
53 | ```
54 |
55 | ### Usage
56 |
57 | ```js
58 | /* main.js */
59 |
60 | /* declare global style. Styles will be injected to all Nho Elements */
61 | Nho.style = `
62 | .box {
63 | background: blue;
64 | color: yellow;
65 | }
66 | `
67 |
68 | class MyCounterChild extends Nho {
69 | render(h) {
70 | /* bind value from props */
71 | return h`
Child: ${this.props.count}
`
72 | }
73 | }
74 |
75 | class MyCounter extends Nho {
76 | setup() {
77 | /* this method runs before mount */
78 |
79 | /* create component state using "this.reactive", state must be an object */
80 | this.state = this.reactive({ count: 1 });
81 |
82 | /* only use ref for storing DOM reference */
83 | this.pRef = this.ref();
84 |
85 | /* effect */
86 | this.effect(
87 | // effect value: fn -> value
88 | () => this.state.count,
89 | // effect callback: fn(old value, new value)
90 | (oldValue, newValue) => {
91 | console.log(oldValue, newValue)
92 | }
93 | )
94 | }
95 |
96 | onMounted() {
97 | /* this method runs after mount */
98 | console.log('Mounted');
99 | }
100 |
101 | onUpdated() {
102 | /* this method runs after each update. */
103 | console.log('Updated');
104 |
105 | /* P tag ref */
106 | console.log('P Ref', this.pRef?.current);
107 | }
108 |
109 | onUnmounted() {
110 | /* this method runs before unmount */
111 | console.log('Before unmount');
112 | }
113 |
114 | addCount() {
115 | /* update state by redeclaring its key-value. Avoid updating the whole state. */
116 | this.state.count += 1;
117 | }
118 |
119 | render(h) {
120 | /* this method is used to render */
121 |
122 | /*
123 | JSX template alike
124 | - Must have only 1 root element
125 | - Bind state / event using value in literal string
126 | - Pass state to child element using props with 'p:' prefix
127 | */
128 | return h`
129 |
130 |
Name: ${this.state.count}
131 |
132 |
133 |
134 | `
135 | }
136 | }
137 |
138 | customElements.define("my-counter", MyCounter);
139 | customElements.define("my-counter-child", MyCounterChild);
140 | ```
141 |
142 | ```html
143 | /* index.html */
144 |
145 | ```
146 |
147 | ### Notice
148 | - **Avoid** using these below properties inside Nho Component since they are reversed Nho's properties.
149 |
150 | Element properties
151 |
152 | ```
153 | _op, _ef, _ev, _sr, _ga, _nm, _sc, _p, _u, _h, _e, _t
154 | ```
155 |
156 | ```
157 | setup, onMounted, onUnmounted, onUpdated, effect, ref, reactive, render
158 | ```
159 |
160 | Class properties
161 | ```
162 | _c, style
163 | ```
164 |
165 | ### How it works
166 |
167 | - It's better to dive into the code, but here is a quick sketch about how `Nho` works.
168 |
169 | 
170 |
171 | ### Mentions
172 |
173 | - [Frontend Focus's #651 issue](https://frontendfoc.us/issues/651)
174 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.3.3/schema.json",
3 | "organizeImports": {
4 | "enabled": true
5 | },
6 | "linter": {
7 | "enabled": true,
8 | "rules": {
9 | "recommended": true,
10 | "complexity": {
11 | "noForEach": "off"
12 | },
13 | "performance": {
14 | "noAccumulatingSpread": "off"
15 | },
16 | "style": {
17 | "useConst": "off"
18 | }
19 | }
20 | },
21 | "formatter": {
22 | "enabled": true,
23 | "formatWithErrors": false,
24 | "indentStyle": "space",
25 | "indentWidth": 2,
26 | "lineWidth": 120,
27 | "ignore": []
28 | },
29 | "files": {
30 | "ignore": ["dist/*", "playground/dist/*"]
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Nho Example
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/example/main.e2e.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | import { expect, test } from "@playwright/test";
3 |
4 | const delay = (m = 1000) => new Promise((r) => setTimeout(r, m));
5 |
6 | test("has correct title", async ({ page }) => {
7 | await page.goto("/");
8 | await expect(page).toHaveTitle(/Nho Example/);
9 | });
10 |
11 | test("has correct labels", async ({ page }) => {
12 | await page.goto("/");
13 | await expect(page.locator("h1")).toHaveText(/Albums/);
14 |
15 | const inputPlaceholderText = await page.locator("input").getAttribute("placeholder");
16 | await expect(inputPlaceholderText).toBe("Search album");
17 | });
18 |
19 | test("opens selected album on click", async ({ page }) => {
20 | await page.goto("/");
21 |
22 | // opens the first album
23 | await page.locator("button").first().click();
24 | await delay(1000);
25 |
26 | await expect(page.locator(".selected-title h1")).toHaveText("Album 1 images");
27 | await expect(page.locator(".selected-close")).toBeVisible();
28 |
29 | // closes
30 | await page.locator("button.selected-close").click();
31 | await expect(page.locator(".selected-close")).toBeHidden();
32 | });
33 |
34 | test("searches albums", async ({ page }) => {
35 | await page.goto("/");
36 |
37 | // search
38 | await page.locator("input").fill("omnis");
39 | await delay(1000);
40 |
41 | await expect(page.locator(".album-item").first().locator("div")).toHaveText("omnis laborum odio");
42 | });
43 |
--------------------------------------------------------------------------------
/example/main.js:
--------------------------------------------------------------------------------
1 | import { Nho } from "@";
2 |
3 | Nho.style = `
4 | * {
5 | box-sizing: border-box;
6 | margin: 0;
7 | padding: 0;
8 | font-family: system-ui, sans-serif;
9 | }
10 |
11 | h1 {
12 | margin-bottom: 20px;
13 | }
14 |
15 | input {
16 | width: 100%;
17 | margin-bottom: 20px;
18 | border-radius: 0;
19 | outline: none;
20 | padding: 8px;
21 | border: 1px solid black;
22 | }
23 |
24 | button {
25 | cursor: pointer;
26 | border: none;
27 | padding: 8px;
28 | }
29 |
30 | .selected {
31 | position: fixed;
32 | top: 0;
33 | left: 0;
34 | width: 100%;
35 | height: 100%;
36 | background: PaleTurquoise;
37 | align-items: center;
38 | overflow: auto;
39 | padding: 16px;
40 | }
41 |
42 | .selected-title {
43 | display: flex;
44 | justify-content: space-between;
45 | align-items: center;
46 | margin-bottom: 20px;
47 | }
48 |
49 | .selected-close {
50 | background: black;
51 | color: white;
52 | }
53 |
54 | .selected .images, .albums {
55 | display: flex;
56 | flex-direction: column;
57 | gap: 16px;
58 | }
59 |
60 | .selected .image-item {
61 | display: flex;
62 | gap: 16px;
63 | align-items: center;
64 | }
65 |
66 | .selected .image-item div:first-child {
67 | height: 48px;
68 | width: 48px;
69 | min-height: 48px;
70 | min-width: 48px;
71 | border: 1px dotted grey;
72 | }
73 |
74 | .album-item {
75 | display: flex;
76 | gap: 16px;
77 | align-items: center;
78 | }
79 |
80 | .album-item button {
81 | background: PaleTurquoise;
82 | color: blue;
83 | flex-shrink: 0;
84 | }
85 |
86 | .album-item dev {
87 | flex: 1;
88 | }
89 | `;
90 |
91 | class SelectedAlbum extends Nho {
92 | render(h) {
93 | return h`
94 |
95 |
96 |
97 |
Album ${this.props.album[0].albumId} images
98 |
99 |
100 |
101 | ${this.props.album.map(
102 | (img) => h`
103 |
104 |
105 |
${img.title}
106 |
107 | `,
108 | )}
109 |
110 |
111 |
112 | `;
113 | }
114 | }
115 |
116 | class AlbumItem extends Nho {
117 | render(h) {
118 | return h`
119 |
120 |
121 |
${this.props.title}
122 |
123 | `;
124 | }
125 | }
126 |
127 | class AlbumList extends Nho {
128 | setup() {
129 | this.state = this.reactive({
130 | albums: [],
131 | isFetched: false,
132 | selectedAlbum: undefined,
133 | search: "",
134 | });
135 | this.h1Ref = this.ref();
136 |
137 | this.effect(
138 | () => this.state.search,
139 | (oldValue, newValue) => console.log(oldValue, newValue),
140 | );
141 | }
142 |
143 | matchedAlbums() {
144 | if (!this.state.search) return this.state.albums;
145 | return this.state.albums.filter((v) => v.title.toLowerCase().includes(this.state.search.toLowerCase()));
146 | }
147 |
148 | async onMounted() {
149 | const response = await fetch("https://jsonplaceholder.typicode.com/albums");
150 | this.state.albums = (await response.json()) || [];
151 | this.state.isFetched = true;
152 | }
153 |
154 | onUpdated() {
155 | console.log("H1 Ref", this.h1Ref?.current);
156 | }
157 |
158 | async viewAlbum(id) {
159 | const response = await fetch(`https://jsonplaceholder.typicode.com/albums/${id}/photos`);
160 | this.state.selectedAlbum = (await response.json()) || [];
161 | document.body.style.overflow = "hidden";
162 | }
163 |
164 | hideAlbum() {
165 | this.state.selectedAlbum = undefined;
166 | document.body.style.overflow = "initial";
167 | }
168 |
169 | searchValue(e) {
170 | this.state.search = e.target.value;
171 | }
172 |
173 | render(h) {
174 | if (!this.state.isFetched) return h`fetching albums...
`;
175 | if (!this.state.albums.length) return h`no albums found
`;
176 |
177 | return h`
178 |
179 |
Albums
180 |
185 |
186 | ${this.matchedAlbums().map(
187 | (album) => h`
this.viewAlbum(album.id)}>`,
188 | )}
189 |
190 | ${
191 | this.state.selectedAlbum
192 | ? h`
`
193 | : ""
194 | }
195 |
196 | `;
197 | }
198 | }
199 |
200 | customElements.define("album-list", AlbumList);
201 | customElements.define("album-item", AlbumItem);
202 | customElements.define("selected-album", SelectedAlbum);
203 |
--------------------------------------------------------------------------------
/hiw.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/anh-ld/nho/a0971be3c000325da782ac99e87421aaa64e202f/hiw.webp
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nho",
3 | "version": "0.2.0",
4 | "description": "1KB Web Component Abstraction",
5 | "keywords": ["nho", "web component"],
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/anh-ld/nho.git"
9 | },
10 | "author": {
11 | "name": "Anh Le",
12 | "email": "ledzanh@gmail.com"
13 | },
14 | "type": "module",
15 | "exports": {
16 | "./package.json": "./package.json",
17 | ".": {
18 | "import": "./dist/index.es.js",
19 | "default": "./dist/index.umd.js"
20 | },
21 | "./dist/": "./dist/"
22 | },
23 | "main": "./dist/index.umd.js",
24 | "files": ["dist", "src", "*.md"],
25 | "scripts": {
26 | "----------BUILD----------": "",
27 | "build": "vite build --mode=lib",
28 | "build:example": "vite build --mode=example",
29 | "----------E2E-TEST----------": "",
30 | "e2e": "playwright test",
31 | "e2e:ui": "playwright test --ui",
32 | "----------UNIT-TEST----------": "",
33 | "coverage": "vitest --coverage",
34 | "test": "vitest run",
35 | "test:watch": "vitest",
36 | "----------DEV----------": "",
37 | "dev": "vite",
38 | "----------OTHERS----------": "",
39 | "postinstall": "simple-git-hooks",
40 | "release": "release-it",
41 | "check": "npx @biomejs/biome check --write .",
42 | "clean": "rm -rf dist example/dist playwright-report test-results"
43 | },
44 | "simple-git-hooks": {
45 | "pre-commit": "npm run test && npm run check && git add .",
46 | "commit-msg": "npx @n6ai/verify-commit-msg@latest $1"
47 | },
48 | "devDependencies": {
49 | "@biomejs/biome": "^1.8.3",
50 | "@n6ai/verify-commit-msg": "^1.2.0",
51 | "@playwright/test": "^1.45.1",
52 | "@testing-library/jest-dom": "^6.4.6",
53 | "@testing-library/user-event": "^14.5.2",
54 | "@vitest/coverage-v8": "^0.34.6",
55 | "jsdom": "^22.1.0",
56 | "playwright": "^1.45.1",
57 | "release-it": "^16.3.0",
58 | "simple-git-hooks": "^2.11.1",
59 | "vite": "^4.5.3",
60 | "vitest": "^0.34.6"
61 | },
62 | "release-it": {
63 | "git": {
64 | "commitMessage": "chore: release v${version}"
65 | },
66 | "github": {
67 | "release": true
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/playwright.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | import { defineConfig, devices } from "@playwright/test";
3 |
4 | const URL = "http://localhost:5173";
5 |
6 | /**
7 | * @see https://playwright.dev/docs/test-configuration
8 | */
9 | export default defineConfig({
10 | webServer: {
11 | command: "npm run dev",
12 | url: URL,
13 | reuseExistingServer: !process.env.CI,
14 | timeout: 2000,
15 | },
16 | testMatch: "*.e2e.*",
17 | testDir: "./example",
18 | /* Run tests in files in parallel */
19 | fullyParallel: true,
20 | /* Fail the build on CI if you accidentally left test.only in the source code. */
21 | forbidOnly: !!process.env.CI,
22 | /* Retry on CI only */
23 | retries: process.env.CI ? 2 : 0,
24 | /* Opt out of parallel tests on CI. */
25 | workers: process.env.CI ? 1 : undefined,
26 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */
27 | reporter: "html",
28 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
29 | use: {
30 | trace: "on-first-retry",
31 | baseURL: URL,
32 | },
33 |
34 | /* Configure projects for major browsers */
35 | projects: [
36 | {
37 | name: "chromium",
38 | use: { ...devices["Desktop Chrome"] },
39 | },
40 | {
41 | name: "firefox",
42 | use: { ...devices["Desktop Firefox"] },
43 | },
44 | {
45 | name: "webkit",
46 | use: { ...devices["Desktop Safari"] },
47 | },
48 | ],
49 | });
50 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export class Nho extends HTMLElement {
2 | /* NATIVE HTML ELEMENT LIFECYCLE */
3 |
4 | constructor() {
5 | super();
6 |
7 | /* old props */
8 | this._op = {};
9 |
10 | /* current props */
11 | this.props = {};
12 |
13 | /*
14 | key: effect function, value: effect callback
15 | e.g: () => this.state.count : (oldValue, newValue) => console.log(oldValue, newValue)
16 | */
17 | this._ef = new Map();
18 |
19 | /*
20 | key: effect function, value: effect function value
21 | e.g: () => this.state.count : 100
22 | */
23 | this._ev = new Map();
24 |
25 | this.attachShadow({ mode: "open" });
26 | }
27 |
28 | connectedCallback() {
29 | /* shadow root alias */
30 | this._sr = this.shadowRoot;
31 |
32 | /* set host attributes to be props */
33 | this._ga(this._sr.host.attributes);
34 |
35 | /* run setup before mounting */
36 | this.setup?.();
37 |
38 | /* update without callback fn */
39 | this._u();
40 |
41 | /* run onMounted callback if needed */
42 | this.onMounted?.();
43 | }
44 |
45 | disconnectedCallback() {
46 | this.onUnmounted?.();
47 | }
48 |
49 | /* INTERNAL FUNCTIONS */
50 |
51 | /* update */
52 | _u(shouldShallowCompareProps = false) {
53 | /* avoid new update when props is not changed (shallow comparison) */
54 | if (shouldShallowCompareProps && this._sc(this._op, this.props)) return;
55 |
56 | /* get html string */
57 | let renderString = this.render(this._h.bind(this));
58 | let { body } = new DOMParser().parseFromString(renderString, "text/html");
59 |
60 | /* create style element */
61 | let styleElement = document.createElement("style");
62 | styleElement.innerHTML = Nho.style;
63 |
64 | /* run patch */
65 | this._p(this._sr, body, styleElement);
66 |
67 | /* bind events to dom after patching */
68 | this._e();
69 |
70 | /* run onUpdated callback if needed */
71 | this.onUpdated?.();
72 |
73 | /* run effects if needed */
74 | this._ef.forEach((callback, valueFn) => {
75 | /* get value before and after update */
76 | let valueBeforeUpdate = this._ev.get(valueFn);
77 | let valueAfterUpdate = valueFn.bind(this)();
78 |
79 | /* run effect if value changed */
80 | if (valueBeforeUpdate !== valueAfterUpdate) {
81 | callback.bind(this)(valueBeforeUpdate, valueAfterUpdate);
82 | }
83 |
84 | /* update new effect value */
85 | this._ev.set(valueFn, valueAfterUpdate);
86 | });
87 | }
88 |
89 | /* patching, dom diffing */
90 | _p(current, next, styleNode) {
91 | let cNodes = this._nm(current.childNodes);
92 | let nNodes = this._nm(next.childNodes);
93 | if (styleNode) nNodes.unshift(styleNode);
94 |
95 | /* compare new nodes and old nodes, if number of old nodes > new nodes, then remove the gap */
96 | let gap = cNodes.length - nNodes.length;
97 | if (gap > 0) for (; gap > 0; gap--) current.removeChild(current.lastChild);
98 |
99 | /* loop through each new node, compare with it's correlative current node */
100 | nNodes.forEach((_, i) => {
101 | let c = cNodes[i];
102 | let n = nNodes[i];
103 |
104 | /* function to clone new node */
105 | let clone = () => n.cloneNode(true);
106 |
107 | /* function to replace old node by new node */
108 | let replace = () => current.replaceChild(clone(), c);
109 |
110 | // if there's no current node, then append new node
111 | if (!c) current.appendChild(clone());
112 | // if they have different tags, then replace current node by new node
113 | else if (c.tagName !== n.tagName) replace();
114 | // if new node has its children, then recursively patch them
115 | else if (n.childNodes.length) this._p(c, n);
116 | // if both current and new nodes are custom elements
117 | // then update props from new node to current node -> run update fn
118 | // c._h is a tricky way to check if it's a Nho custom element
119 | else if (c._h) {
120 | c._ga(n?.attributes);
121 | c._u(true);
122 | }
123 | // if they have different text contents, then replace current node by new node
124 | else if (c.textContent !== n.textContent) replace();
125 |
126 | /* update attributes of current node */
127 | if (c?.attributes) {
128 | /* remove all attributes of current node */
129 | while (c.attributes.length > 0) c.removeAttribute(c.attributes[0].name);
130 |
131 | /* add new attributes from new node to current node */
132 | this._nm(n?.attributes).forEach(({ name, value }) => {
133 | c.setAttribute(name, value);
134 | });
135 | }
136 | });
137 | }
138 |
139 | /* hyper script, render html string */
140 | _h(stringArray, ...valueArray) {
141 | return stringArray
142 | .map((s, index) => {
143 | let currentValue = valueArray[index] || "";
144 | let valueString = currentValue;
145 |
146 | // if string ends with "=", then it's gonna be a value hereafter
147 | if (s.endsWith("=")) {
148 | // if attribute starts with 'p:' or 'on', then cache value
149 | if (/(p:|on|ref).*$/.test(s)) {
150 | let key = Math.random().toString(36);
151 | Nho._c[key] = typeof currentValue === "function" ? currentValue.bind(this) : currentValue;
152 | valueString = key;
153 | }
154 | // else, then stringify
155 | else valueString = JSON.stringify(currentValue);
156 | }
157 | // if value is array, that should be an array of child components, then join it all
158 | else if (Array.isArray(currentValue)) valueString = currentValue.join("");
159 |
160 | return s + valueString;
161 | })
162 | .join("");
163 | }
164 |
165 | /* events to dom */
166 | _e() {
167 | /*
168 | traverse through the dom tree
169 | check if dom attribute key is an event name (starts with "on")
170 | if it's true, then bind cached event handler to that attribute
171 | */
172 | this._sr.querySelectorAll("*").forEach((node) => {
173 | this._nm(node.attributes).forEach(({ name, value }) => {
174 | if (name.startsWith("on")) node[name] = (e) => Nho._c[value].call(this, e);
175 |
176 | if (name === "ref") Nho._c[value].current = node;
177 | });
178 | });
179 | }
180 |
181 | /* API */
182 |
183 | effect(valueFn, callback) {
184 | this._ef.set(valueFn, callback);
185 | this._ev.set(valueFn, valueFn.bind(this)());
186 | }
187 |
188 | ref(initialValue) {
189 | return { current: initialValue };
190 | }
191 |
192 | reactive(state) {
193 | return new Proxy(state, {
194 | set: (target, key, value) => {
195 | if (!(key in target) || target[key] !== value) {
196 | target[key] = value;
197 |
198 | /* batch update after each frame */
199 | if (this._t) cancelAnimationFrame(this._t);
200 | this._t = requestAnimationFrame(() => this._u());
201 | }
202 |
203 | return true;
204 | },
205 | get: (target, key) => target[key],
206 | });
207 | }
208 |
209 | /* HELPER FUNCTIONS */
210 |
211 | /* turn NodeMap to array */
212 | _nm(attributes) {
213 | return [...(attributes || [])];
214 | }
215 |
216 | /* get attributes object */
217 | _ga(attributes) {
218 | /* internally cache old props */
219 | this._op = this.props;
220 |
221 | let createAttributeObject = (acc, { nodeName, nodeValue }) => ({
222 | ...acc,
223 | [nodeName.startsWith("p:") ? nodeName.slice(2) : nodeName]: Nho._c[nodeValue],
224 | });
225 |
226 | /* set new props */
227 | this.props = this._nm(attributes).reduce(createAttributeObject, {});
228 | }
229 |
230 | /* shallow compare 2 objects */
231 | _sc(obj1, obj2) {
232 | /* no length comparison or reference comparison since it's redundant */
233 | return Object.keys(obj1).every((key) => obj1[key] === obj2[key]);
234 | }
235 |
236 | /* STATIC */
237 |
238 | /* style */
239 | static style = "";
240 |
241 | /* cache */
242 | static _c = {};
243 | }
244 |
245 | /* FOR DEVELOPMENT PURPOSES ONLY */
246 | if (import.meta.env.DEV) window.Nho = Nho;
247 |
--------------------------------------------------------------------------------
/src/index.test.js:
--------------------------------------------------------------------------------
1 | import "@testing-library/jest-dom/vitest";
2 | import { afterEach, beforeEach, describe, expect, it } from "vitest";
3 | import { Nho } from "./";
4 |
5 | const tick = () => new Promise((r) => setTimeout(r, 100));
6 |
7 | class ChildElement extends Nho {
8 | render(h) {
9 | return h`${this.props.count}
`;
10 | }
11 | }
12 |
13 | class ParentElement extends Nho {
14 | setup() {
15 | this.state = this.reactive({ count: 1 });
16 | }
17 |
18 | increase() {
19 | this.state.count++;
20 | }
21 |
22 | render(h) {
23 | return h`
24 |
25 |
Count: ${this.state.count}
26 |
27 | ${Array.from(Array(this.state.count), (_, index) => index + 1).map(
28 | (v) => h`
`,
29 | )}
30 |
31 | `;
32 | }
33 | }
34 |
35 | customElements.define("parent-element", ParentElement);
36 | customElements.define("child-element", ChildElement);
37 |
38 | describe("test the library", () => {
39 | const name = "parent-element";
40 | const childName = "child-element";
41 |
42 | beforeEach(() => {
43 | const element = document.createElement(name);
44 | document.body.appendChild(element);
45 |
46 | Nho.style = `
47 | p {
48 | color: red;
49 | }
50 | `;
51 | });
52 |
53 | afterEach(() => {
54 | const element = document.querySelector(name);
55 | document.body.removeChild(element);
56 | });
57 |
58 | it("should render the custom element", () => {
59 | const element = document.querySelector(name);
60 | expect(element).toBeInTheDocument();
61 | });
62 |
63 | it("should render correct content", () => {
64 | const element = document.querySelector(name);
65 | const content = element.shadowRoot.querySelector("p");
66 |
67 | expect(content).toBeInTheDocument();
68 | expect(content).toHaveTextContent("Count: 1");
69 | });
70 |
71 | it("should behave correctly when an event happens", async () => {
72 | const element = document.querySelector(name);
73 | const content = element.shadowRoot.querySelector("p");
74 | const button = element.shadowRoot.querySelector("button");
75 |
76 | expect(content).toHaveTextContent("Count: 1");
77 | expect(button).toHaveTextContent("Increase");
78 |
79 | button.click();
80 | await tick();
81 |
82 | expect(content).toHaveTextContent("Count: 2");
83 | });
84 |
85 | it("should render child elements with correct props", async () => {
86 | const element = document.querySelector(name);
87 | const button = element.shadowRoot.querySelector("button");
88 | const getChild = () => element.shadowRoot.querySelectorAll(childName);
89 |
90 | expect(getChild().length).toBe(1);
91 |
92 | button.click();
93 | await tick();
94 |
95 | let newChild = getChild();
96 | expect(newChild.length).toBe(2);
97 |
98 | expect(newChild[0].shadowRoot).toHaveTextContent("1");
99 | expect(newChild[1].shadowRoot).toHaveTextContent("2");
100 | });
101 |
102 | it("should render correct styles", async () => {
103 | const element = document.querySelector(name);
104 | const children = element.shadowRoot.querySelectorAll(childName);
105 | const styleTag = children[0].shadowRoot.querySelector("style");
106 |
107 | expect(styleTag).toBeInTheDocument();
108 | expect(styleTag).toHaveTextContent("color: red;");
109 | });
110 | });
111 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { resolve } from "node:path";
2 | import { transform } from "esbuild";
3 | import { defineConfig } from "vite";
4 | import { name } from "./package.json";
5 |
6 | // minify plugin, based on esbuild's native method
7 | const minifyEs = {
8 | renderChunk: {
9 | order: "post",
10 | async handler(code, _, { format }) {
11 | if (format === "es") return await transform(code, { minify: true });
12 |
13 | return code;
14 | },
15 | },
16 | };
17 |
18 | // paths
19 | const srcPath = resolve(__dirname, "./src");
20 | const examplePath = resolve(__dirname, "./example");
21 | const rootPath = resolve(__dirname, "./");
22 |
23 | // config
24 | export default defineConfig(({ command, mode }) => {
25 | const config = {
26 | plugins: [minifyEs],
27 | resolve: {
28 | alias: {
29 | "@": srcPath,
30 | },
31 | },
32 | build: {
33 | emptyOutDir: true,
34 | },
35 | test: {
36 | root: "./src",
37 | environment: "jsdom",
38 | },
39 | };
40 |
41 | if (command === "serve" || (command === "build" && mode === "example")) {
42 | config.root = examplePath;
43 | }
44 |
45 | if (command === "build" && mode === "lib") {
46 | config.root = rootPath;
47 |
48 | config.build.lib = {
49 | entry: srcPath,
50 | formats: ["es", "umd"],
51 | name,
52 | fileName: (format) => `index.${format}.js`,
53 | };
54 | }
55 |
56 | return config;
57 | });
58 |
--------------------------------------------------------------------------------