├── .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 | ![How Nho works](./hiw.webp) 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 | --------------------------------------------------------------------------------