├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── browser.ts ├── core.ts ├── demo ├── common.css ├── hybrid.html ├── hybrid.ts ├── index.html ├── index.ts ├── quick-example.html ├── quick-example.ts ├── signup.html ├── signup.ts ├── style.html └── style.ts ├── helpers.ts ├── index.ts ├── package.json ├── selector.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | pnpm-lock.yaml 3 | *.tgz 4 | *.js 5 | *.d.ts 6 | *.tsbuildinfo 7 | .vscode 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "arrowParens": "avoid", 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) [2023], [Beeno Tung (Tung Cheung Leong)] 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dom-proxy 2 | 3 | Develop lightweight and declarative UI with automatic dependency tracking in Javascript/Typescript without boilerplate code, VDOM, nor compiler. 4 | 5 | [![npm Package Version](https://img.shields.io/npm/v/dom-proxy)](https://www.npmjs.com/package/dom-proxy) 6 | [![Minified Package Size](https://img.shields.io/bundlephobia/min/dom-proxy)](https://bundlephobia.com/package/dom-proxy) 7 | [![Minified and Gzipped Package Size](https://img.shields.io/bundlephobia/minzip/dom-proxy)](https://bundlephobia.com/package/dom-proxy) 8 | 9 | Demo: https://dom-proxy.surge.sh 10 | 11 | ## Table of Content 12 | 13 | - [Quick Example](#quick-example) 14 | - [Installation](#installation) 15 | - [How it works](#how-it-works) 16 | - [Usage Examples](#usage-examples) 17 | - [Example using creation functions](#example-using-creation-functions) 18 | - [Example using selector functions](#example-using-selector-functions) 19 | - [Typescript Signature](#typescript-signature) 20 | - [Reactive function](#reactive-function) 21 | - [Selector functions](#selector-functions) 22 | - [Creation functions](#creation-functions) 23 | - [Creation helper functions](#creation-helper-functions) 24 | - [Partially applied creation functions](#partially-applied-creation-functions) 25 | - [Options Types / Output Types](#options-types--output-types) 26 | - [FOSS License](#license) 27 | 28 | ## Quick Example 29 | 30 | ```javascript 31 | // elements type are inferred from selector 32 | let { password, showPw } = queryElementProxies({ 33 | showPw: 'input#show-pw', 34 | password: '[name=password]', 35 | }) 36 | 37 | watch(() => { 38 | password.type = showPw.checked ? 'text' : 'password' 39 | }) 40 | 41 | // create new element or text node, then proxy on it 42 | let nameInput = input({ placeholder: 'guest', id: 'visitor-name' }) 43 | let nameText = text() 44 | 45 | // auto re-run when the value in changed 46 | watch(() => { 47 | nameText.textContent = nameInput.value || nameInput.placeholder 48 | }) 49 | 50 | document.body.appendChild( 51 | fragment([ 52 | label({ textContent: 'name: ', htmlFor: nameInput.id }), 53 | nameInput, 54 | p(['hello, ', nameText]), 55 | ]), 56 | ) 57 | ``` 58 | 59 | Complete example see [quick-example.ts](./demo/quick-example.ts) 60 | 61 | (Explained in the [usage examples](#usage-examples) section) 62 | 63 | ## Installation 64 | 65 | You can get dom-proxy via npm: 66 | 67 | ```bash 68 | npm install dom-proxy 69 | ``` 70 | 71 | Then import from typescript using named import or star import: 72 | 73 | ```typescript 74 | import { watch } from 'dom-proxy' 75 | import * as domProxy from 'dom-proxy' 76 | ``` 77 | 78 | Or import from javascript as commonjs module: 79 | 80 | ```javascript 81 | var domProxy = require('dom-proxy') 82 | ``` 83 | 84 | You can also get dom-proxy directly in html via CDN: 85 | 86 | ```html 87 | 88 | 91 | ``` 92 | 93 | ## How it works 94 | 95 | A DOM proxy can be used to enable reactive programming by intercepting access to a DOM node's properties and triggering updates to the UI whenever those properties are changed. 96 | 97 | Here's an example of how a DOM proxy can be used to enable reactive programming: 98 | 99 | ```javascript 100 | const nameInput = document.querySelector('input#name') 101 | const message = document.querySelector('p#message') 102 | 103 | const inputProxy = new Proxy(nameInput, { 104 | set(target, property, value) { 105 | target[property] = value 106 | message.textContent = 'Hello, ' + value + '!' 107 | return true 108 | }, 109 | }) 110 | 111 | inputProxy.value = 'world' 112 | ``` 113 | 114 | In this example, we've created a reactive input element by creating a DOM proxy for the input element. The set trap of the proxy is used to intercept any changes made to the input's value, and it updates the output element's text content to reflect the new value. 115 | 116 | However, it is quite verbose to work with the Proxy API directly. 117 | 118 | `dom-proxy` allows you to do reactive programming concisely. With `dom-proxy`, above example can be written as: 119 | 120 | ```javascript 121 | let { nameInput, message } = queryElementProxies({ 122 | nameInput: 'input#name', 123 | message: 'p#message', 124 | }) 125 | 126 | watch(() => { 127 | message.textContent = 'Hello, ' + nameInput.value + '!' 128 | }) 129 | 130 | nameInput.value = 'world' 131 | ``` 132 | 133 | In above example, the `textContent` of `message` depends on the `value` of `nameInput`, this dependency is automatically tracked without explicitly coding. 134 | 135 | This is in contrast to `useEffect()` in `React` where you have to manually maintain the dependency list. Also, `dom-proxy` works in mutable manner, hence we don't need to run "diffing" algorithm on VDOM to reconciliate the UI. 136 | 137 | ## Usage Examples 138 | 139 | More examples can be found in [./demo](./demo): 140 | 141 | - [index.ts](./demo/index.ts) 142 | - [signup.ts](./demo/signup.ts) 143 | - [hybrid.html](./demo/hybrid.html) + [hybrid.ts](./demo/hybrid.ts) 144 | - [style.html](./demo/style.html) + [style.ts](./demo/style.ts) 145 | 146 | ### Example using creation functions 147 | 148 | This example consists of a input and text message. 149 | 150 | With the `watch()` function, the text message is initialized and updated according to the input value. We don't need to specify the dependency explicitly. 151 | 152 | ```typescript 153 | import { watch, input, span, label, fragment } from 'dom-proxy' 154 | 155 | let nameInput = input({ placeholder: 'guest', id: 'visitor-name' }) 156 | let nameSpan = span() 157 | 158 | // the read-dependencies are tracked automatically 159 | watch(() => { 160 | nameSpan.textContent = nameInput.value || nameInput.placeholder 161 | }) 162 | 163 | document.body.appendChild( 164 | // use a DocumentFragment to contain the elements 165 | fragment([ 166 | label({ textContent: 'name: ', htmlFor: nameInput.id }), 167 | nameInput, 168 | p(['hello, ', nameSpan]), 169 | ]), 170 | ) 171 | ``` 172 | 173 | ### Example using selector functions 174 | 175 | This example query and proxy the existing elements from the DOM, then setup interactive logics in the `watch()` function. 176 | 177 | If the selectors don't match any element, it will throw error. 178 | 179 | ```typescript 180 | import { ProxyNode, watch } from 'dom-proxy' 181 | import { queryElement, queryElementProxies } from 'dom-proxy' 182 | 183 | let loginForm = queryElement('form#loginForm') // infer to be HTMLFormElement 184 | let { password, showPw } = queryElementProxies( 185 | { 186 | showPw: 'input#show-pw', // infer to be ProxyNode <- "input" tagName 187 | password: '[name=password]', // fallback to be ProxyNode <- "[name=.*]" attribute without tagName 188 | }, 189 | loginForm, 190 | ) 191 | 192 | watch(() => { 193 | password.type = showPw.checked ? 'text' : 'password' 194 | }) 195 | ``` 196 | 197 | ## Typescript Signature 198 | 199 | The types shown in this section are simplified, see the `.d.ts` files published in the npm package for complete types. 200 | 201 | ### Reactive function 202 | 203 | ```typescript 204 | /** @description run once immediately, auto track dependency and re-run */ 205 | function watch( 206 | fn: Function, 207 | options?: { 208 | listen?: 'change' | 'input' // default 'input' 209 | }, 210 | ): void 211 | ``` 212 | 213 | ### Selector functions 214 | 215 | These query selector functions (except `queryAll*()`) will throw error if no elements match the selectors. 216 | 217 | The corresponding element type is inferred from the tag name in the selector. (e.g. `select[name=theme]` will be inferred as `HTMLSelectElement`) 218 | 219 | If the selector doesn't contain the tag name but containing "name" attribute (e.g. `[name=password]`), the inferred type will be `HTMLInputElement`. 220 | 221 | If the element type cannot be determined, it will fallback to `Element` type. 222 | 223 | ```typescript 224 | function queryElement( 225 | selector: Selector, 226 | parent?: ParentNode, 227 | ): InferElement 228 | 229 | function queryElementProxy( 230 | selector: Selector, 231 | parent?: ParentNode, 232 | ): ProxyNode> 233 | 234 | function queryAllElements( 235 | selector: Selector, 236 | parent?: ParentNode, 237 | ): InferElement[] 238 | 239 | function queryAllElementProxies( 240 | selector: Selector, 241 | parent?: ParentNode, 242 | ): ProxyNode>[] 243 | 244 | function queryElements>( 245 | selectors: SelectorDict, 246 | parent?: ParentNode, 247 | ): { [P in keyof SelectorDict]: InferElement } 248 | 249 | function queryElementProxies>( 250 | selectors: SelectorDict, 251 | parent?: ParentNode, 252 | ): { [P in keyof SelectorDict]: ProxyNode> } 253 | ``` 254 | 255 | ### Creation functions 256 | 257 | ```typescript 258 | function fragment(nodes: NodeChild[]): DocumentFragment 259 | 260 | /** @alias t, text */ 261 | function createText(value?: string | number): ProxyNode 262 | 263 | /** @alias h, html */ 264 | function createHTMLElement( 265 | tagName: K, 266 | props?: Properties, 267 | children?: NodeChild[], 268 | ): ProxyNode 269 | 270 | /** @alias s, svg */ 271 | function createSVGElement( 272 | tagName: K, 273 | props?: Properties, 274 | children?: NodeChild[], 275 | ): ProxyNode 276 | 277 | function createProxy(node: Node): ProxyNode 278 | ``` 279 | 280 | ### Creation helper functions 281 | 282 | The creation function of most html elements and svg elements are defined as partially applied `createHTMLElement()` or `createSVGElement()`. 283 | 284 | If you need more helper functions (e.g. for custom web components or deprecated elements[1]), you can defined them with `genCreateHTMLElement(tagName)` or `genCreateSVGElement(tagName)` 285 | 286 | The type of creation functions are inferred from the tag name with `HTMLElementTagNameMap` and `SVGElementTagNameMap`. 287 | 288 | Below are some example types: 289 | 290 | ```typescript 291 | // some pre-defined creation helper functions 292 | const div: PartialCreateElement, 293 | p: PartialCreateElement, 294 | a: PartialCreateElement, 295 | label: PartialCreateElement, 296 | input: PartialCreateElement, 297 | path: PartialCreateElement, 298 | polyline: PartialCreateElement, 299 | rect: PartialCreateElement 300 | // and more ... 301 | ``` 302 | 303 | For most elements, the creation functions use the same name as the tag name, however some are renamed to avoid name clash. 304 | 305 | Renamed html element creation functions: 306 | 307 | - `html` -> `htmlElement` 308 | - `s` -> `sElement` 309 | - `script` -> `scriptElement` 310 | - `style` -> `styleElement` 311 | - `title` -> `titleElement` 312 | - `var` -> `varElement` 313 | 314 | Renamed svg elements creation functions: 315 | 316 | - `a` -> `aSVG` 317 | - `script` -> `scriptSVG` 318 | - `style` -> `styleSVG` 319 | - `svg` -> `svgSVG` 320 | - `switch` -> `switchSVG` 321 | - `text` -> `textSVG` 322 | - `title` -> `titleSVG` 323 | 324 |
325 | 326 | Tips to rename the creation functions (click to expand) 327 | 328 | 329 | The creation functions are defined dynamically in the proxy object `createHTMLElementFunctions` and `createSVGElementFunctions` 330 | 331 | If you prefer to rename them with different naming conventions, you can destruct from the proxy object using your preferred name. For example: 332 | 333 | ```typescript 334 | // you can destruct into custom alias from `createHTMLElementFunctions` 335 | const { s, style, var: var_ } = createHTMLElementFunctions 336 | // or destruct from `createSVGElementFunctions` 337 | const { a, text } = createSVGElementFunctions 338 | // or destruct from createElementFunctions, which wraps above two objects as `html` and `svg` 339 | const { 340 | html: { a: html_a, style: htmlStyle }, 341 | svg: { a: svg_a, style: svgStyle }, 342 | } = createElementFunctions 343 | ``` 344 | 345 | You can also use them without renaming, e.g.: 346 | 347 | ```typescript 348 | const h = createHTMLElementFunctions 349 | 350 | let style = document.body.appendChild( 351 | fragment([ 352 | // you can use the creation functions without extracting into top-level const 353 | h.s({ textContent: 'Now on sales' }), 354 | 'Sold out', 355 | ]), 356 | ) 357 | ``` 358 | 359 | The types of the proxies are listed below: 360 | 361 | ```typescript 362 | type CreateHTMLElementFunctions = { 363 | [K in keyof HTMLElementTagNameMap]: PartialCreateElement< 364 | HTMLElementTagNameMap[K] 365 | > 366 | } 367 | const createHTMLElementFunctions: CreateHTMLElementFunctions 368 | 369 | type CreateSVGElementFunctions = { 370 | [K in keyof SVGElementTagNameMap]: PartialCreateElement< 371 | SVGElementTagNameMap[K] 372 | > 373 | } 374 | const createSVGElementFunctions: CreateSVGElementFunctions 375 | 376 | const createElementFunctions: { 377 | html: CreateHTMLElementFunctions 378 | svg: CreateSVGElementFunctions 379 | } 380 | ``` 381 | 382 |
383 | 384 |
385 | 386 | [1]: Some elements are deprecated in html5, e.g. dir, font, frame, frameset, marquee, param. They are not predefined to avoid tsc error in case their type definition are not included. 387 | 388 | ### Partially applied creation functions 389 | 390 | These are some high-order functions that helps to generate type-safe creation functions for specific elements with statically typed properties. 391 | 392 | ```typescript 393 | /** partially applied createHTMLElement */ 394 | function genCreateHTMLElement( 395 | tagName: K, 396 | ): PartialCreateElement 397 | 398 | /** partially applied createSVGElement */ 399 | function genCreateSVGElement( 400 | tagName: K, 401 | ): PartialCreateElement 402 | ``` 403 | 404 | ### Options Types / Output Types 405 | 406 | ```typescript 407 | type ProxyNode = E & { 408 | node: E 409 | } 410 | 411 | type NodeChild = Node | ProxyNode | string | number 412 | 413 | type Properties = Partial<{ 414 | [P in keyof E]?: E[P] extends object ? Partial : E[P] 415 | }> 416 | 417 | interface PartialCreateElement { 418 | (props?: Properties, children?: NodeChild[]): ProxyNode 419 | (children?: NodeChild[]): ProxyNode 420 | } 421 | ``` 422 | 423 | ## License 424 | 425 | This project is licensed with [BSD-2-Clause](./LICENSE) 426 | 427 | This is free, libre, and open-source software. It comes down to four essential freedoms [[ref]](https://seirdy.one/2021/01/27/whatsapp-and-the-domestication-of-users.html#fnref:2): 428 | 429 | - The freedom to run the program as you wish, for any purpose 430 | - The freedom to study how the program works, and change it so it does your computing as you wish 431 | - The freedom to redistribute copies so you can help others 432 | - The freedom to distribute copies of your modified versions to others 433 | -------------------------------------------------------------------------------- /browser.ts: -------------------------------------------------------------------------------- 1 | import * as domProxy from '.' 2 | ;(window as any).domProxy = domProxy 3 | -------------------------------------------------------------------------------- /core.ts: -------------------------------------------------------------------------------- 1 | export { 2 | createText as t, 3 | createText as text, 4 | createHTMLElement as h, 5 | createHTMLElement as html, 6 | createSVGElement as s, 7 | createSVGElement as svg, 8 | } 9 | 10 | export function fragment(nodes: NodeChild[]) { 11 | const fragment = document.createDocumentFragment() 12 | for (const node of nodes) { 13 | appendChild(fragment, node) 14 | } 15 | return fragment 16 | } 17 | 18 | /** @alias t, text */ 19 | export function createText(value: string | number = '') { 20 | const node = document.createTextNode(value as string) 21 | return createProxy(node) 22 | } 23 | 24 | export type WatchOptions = { 25 | listen?: 'change' | 'input' | false // default 'input' 26 | } 27 | 28 | let watchFn: (() => void) | undefined 29 | let watchOptions: WatchOptions | undefined 30 | 31 | /** @description run once immediately, auto track dependency and re-run */ 32 | export function watch(fn: () => void, options?: WatchOptions) { 33 | watchFn = fn 34 | watchOptions = options 35 | fn() 36 | watchFn = undefined 37 | watchOptions = undefined 38 | } 39 | 40 | export type ProxyNode = E & { node: E } 41 | 42 | let resetTimer: ReturnType | null = null 43 | const resetFns = new Set<() => void>() 44 | function resetTimeout() { 45 | for (let fn of resetFns) { 46 | try { 47 | fn() 48 | } catch (error) { 49 | console.error(error) 50 | } 51 | } 52 | resetFns.clear() 53 | resetTimer = null 54 | } 55 | 56 | const proxySymbol = Symbol('proxy') 57 | 58 | function toPropertyKey(p: string) { 59 | return p === 'value' || p === 'valueAsNumber' || p === 'valueAsDate' 60 | ? 'value' 61 | : p 62 | } 63 | 64 | export function createProxy(node: E): ProxyNode { 65 | if (proxySymbol in node) { 66 | return (node as any)[proxySymbol] 67 | } 68 | const deps = new Map void>>() 69 | const proxy = new Proxy(node, { 70 | get(target, p, receiver) { 71 | const listenEventType = watchOptions?.listen ?? 'input' 72 | if (listenEventType && watchFn && typeof p === 'string') { 73 | const key = toPropertyKey(p) 74 | const fn = watchFn 75 | if (key === 'value' || key === 'checked') { 76 | target.addEventListener( 77 | listenEventType, 78 | // wrap the function to avoid the default behavior be cancelled 79 | // if the inline-function returns false 80 | () => fn(), 81 | ) 82 | ;(target as Node as HTMLInputElement).form?.addEventListener( 83 | 'reset', 84 | () => { 85 | resetFns.add(fn) 86 | if (resetTimer) return 87 | resetTimer = setTimeout(resetTimeout) 88 | }, 89 | ) 90 | } 91 | let fns = deps.get(key) 92 | if (!fns) { 93 | fns = new Set() 94 | deps.set(key, fns) 95 | } 96 | fns.add(fn) 97 | } 98 | const value = target[p as keyof E] 99 | if (typeof value === 'function') { 100 | return value.bind(target) 101 | } 102 | return value 103 | }, 104 | set(target, p, value, receiver) { 105 | target[p as keyof E] = value 106 | if (typeof p === 'string') { 107 | const key = toPropertyKey(p) 108 | const fns = deps.get(key) 109 | if (fns) { 110 | for (const fn of fns) { 111 | fn() 112 | } 113 | } 114 | } 115 | return true 116 | }, 117 | }) 118 | return ((node as any)[proxySymbol] = Object.assign(proxy, { node })) 119 | } 120 | 121 | export type NodeChild = Node | { node: Node } | string | number 122 | 123 | export type Properties = Partial<{ 124 | [P in keyof E]?: E[P] extends object ? Partial : E[P] 125 | }> 126 | 127 | export interface PartialCreateElement { 128 | (props?: Properties, children?: NodeChild[]): ProxyNode 129 | (children?: NodeChild[]): ProxyNode 130 | } 131 | 132 | /** @description higher-function, partially applied createHTMLElement */ 133 | export function genCreateHTMLElement( 134 | tagName: K, 135 | ): PartialCreateElement { 136 | return (props_or_children?, children?) => 137 | Array.isArray(props_or_children) 138 | ? createHTMLElement(tagName, undefined, props_or_children) 139 | : createHTMLElement( 140 | tagName, 141 | props_or_children, 142 | children as NodeChild[] | undefined, 143 | ) 144 | } 145 | 146 | /** @description higher-function, partially applied createSVGElement */ 147 | export function genCreateSVGElement( 148 | tagName: K, 149 | ): PartialCreateElement { 150 | return (props_or_children?, children?) => 151 | Array.isArray(props_or_children) 152 | ? createSVGElement(tagName, undefined, props_or_children) 153 | : createSVGElement( 154 | tagName, 155 | props_or_children, 156 | children as NodeChild[] | undefined, 157 | ) 158 | } 159 | 160 | /** @alias h, html */ 161 | export function createHTMLElement( 162 | tagName: K, 163 | props?: Properties, 164 | children?: NodeChild[], 165 | ) { 166 | const node = document.createElement(tagName) 167 | applyAttrs(node, props, children) 168 | return createProxy(node) 169 | } 170 | 171 | /** @alias s, svg */ 172 | export function createSVGElement( 173 | tagName: K, 174 | props?: Properties, 175 | children?: NodeChild[], 176 | ) { 177 | const node = document.createElementNS('http://www.w3.org/2000/svg', tagName) 178 | applyAttrs(node, props, children) 179 | return createProxy(node) 180 | } 181 | 182 | function applyAttrs( 183 | node: E, 184 | props?: Properties, 185 | children?: NodeChild[], 186 | ) { 187 | if (props) { 188 | for (let p in props) { 189 | let value = props[p] 190 | if (value !== null && typeof value === 'object' && p in node) { 191 | Object.assign((node as any)[p], value) 192 | } else { 193 | ;(node as any)[p] = value 194 | } 195 | } 196 | } 197 | 198 | if (children) { 199 | for (const child of children) { 200 | appendChild(node, child) 201 | } 202 | } 203 | 204 | // to set the value of select after all the options are appended 205 | if ( 206 | node instanceof HTMLSelectElement && 207 | children && 208 | children.length > 0 && 209 | props && 210 | 'value' in props 211 | ) { 212 | node.value = props.value as string 213 | } 214 | } 215 | 216 | export function appendChild( 217 | parent: ParentNode, 218 | child: Node | { node: Node } | string | number, 219 | ) { 220 | if (typeof child == 'string') { 221 | parent.appendChild(document.createTextNode(child)) 222 | } else if (typeof child == 'number') { 223 | parent.appendChild(document.createTextNode(String(child))) 224 | } else if ('node' in child) { 225 | parent.appendChild(child.node) 226 | } else { 227 | parent.appendChild(child) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /demo/common.css: -------------------------------------------------------------------------------- 1 | footer { 2 | margin-top: 1.5rem; 3 | } 4 | -------------------------------------------------------------------------------- /demo/hybrid.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hybrid Demo 8 | 9 | 20 | 21 | 22 |

Hybrid Demo

23 |

24 | This page demo how to use dom-proxy with native dom elements (that are 25 | already present in the DOM). 26 |

27 |

Demo Form

28 |
29 | 33 | 37 | 38 |
39 |
40 | 41 | 42 |
43 | 44 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /demo/hybrid.ts: -------------------------------------------------------------------------------- 1 | import { watch } from '../core' 2 | import { queryElement, queryElementProxies } from '../selector' 3 | 4 | // it will throw error if the selector doesn't match any elements. 5 | let loginForm = queryElement('form#loginForm') // infer to be HTMLFormElement <- "form" tag name 6 | let { username, password, showPw, reset, submit } = queryElementProxies( 7 | { 8 | username: 'input[name=username]', // infer to be ProxyNode <- "input" tagName 9 | password: '[name=password]', // fallback to be ProxyNode <- "[name=.*]" attribute 10 | showPw: 'input#show-pw[type=checkbox]', 11 | reset: 'input[type=reset]', 12 | submit: 'input[type=submit]', 13 | }, 14 | loginForm, 15 | ) 16 | 17 | watch(() => { 18 | password.type = showPw.checked ? 'text' : 'password' 19 | }) 20 | 21 | watch(() => { 22 | reset.disabled = !username.value && !password.value 23 | submit.disabled = !username.value || !password.value 24 | }) 25 | 26 | loginForm.addEventListener('submit', event => { 27 | event.preventDefault() 28 | alert('mock form submission') 29 | }) 30 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | dom-proxy demo 8 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /demo/index.ts: -------------------------------------------------------------------------------- 1 | import { text, fragment, watch } from '../core' 2 | import { h1, a, p, code, h2, input, label, br, button } from '../helpers' 3 | 4 | console.log('ts') 5 | console.time('init') 6 | 7 | document.body.appendChild( 8 | h1([ 9 | 'dom-proxy demo', 10 | a({ textContent: 'git', href: 'https://github.com/beenotung/dom-proxy' }), 11 | a({ textContent: 'npm', href: 'https://www.npmjs.com/package/dom-proxy' }), 12 | ]).node, // get the native element from .node property 13 | // (only necessary when not wrapped by fragment helper function) 14 | ) 15 | 16 | document.body.appendChild( 17 | p([ 18 | "This interactive page is created entirely in Typescript using dom-proxy's creation helper functions and auto-tracking ", 19 | code({ textContent: 'watch()' }), 20 | ' function.', 21 | ]).node, 22 | ) 23 | 24 | let upTimeText = text(0) 25 | 26 | let startTime = Date.now() 27 | setInterval(() => { 28 | upTimeText.textContent = ((Date.now() - startTime) / 1000).toFixed(0) 29 | }, 500) 30 | 31 | document.body.appendChild( 32 | fragment([h2({ textContent: 'up time' }), upTimeText, ' seconds']), 33 | ) 34 | 35 | let nameInput = input({ 36 | placeholder: 'guest', 37 | id: 'visitor-name', 38 | }) 39 | let nameText = text() 40 | let greetDotsText = text() 41 | 42 | // the read-dependencies are tracked automatically 43 | watch( 44 | () => { 45 | nameText.textContent = nameInput.value || nameInput.placeholder 46 | }, 47 | { listen: 'change' }, 48 | ) 49 | watch(() => { 50 | let n = +upTimeText.textContent! % 5 51 | greetDotsText.textContent = '.'.repeat(n) 52 | }) 53 | 54 | document.body.appendChild( 55 | // use a DocumentFragment to contain the elements 56 | fragment([ 57 | h2({ textContent: 'change event demo' }), 58 | label({ textContent: 'name: ', htmlFor: nameInput.id }), 59 | nameInput, 60 | p(['hello, ', nameText, greetDotsText]), 61 | ]), 62 | ) 63 | 64 | let aInput = input({ type: 'number', value: '0' }) 65 | let bInput = input({ 66 | type: 'number', 67 | value: '0', 68 | readOnly: true, 69 | disabled: true, 70 | }) 71 | let cInput = input({ 72 | type: 'number', 73 | value: '0', 74 | readOnly: true, 75 | disabled: true, 76 | }) 77 | 78 | watch(() => { 79 | bInput.value = upTimeText.textContent! 80 | }) 81 | 82 | watch(() => { 83 | cInput.value = String(aInput.valueAsNumber + bInput.valueAsNumber) 84 | }) 85 | 86 | let aText = text() 87 | let bText = text() 88 | let cText = text() 89 | 90 | watch(() => (aText.textContent = String(aInput.valueAsNumber))) 91 | watch(() => (bText.textContent = String(bInput.valueAsNumber))) 92 | watch(() => (cText.textContent = String(cInput.valueAsNumber))) 93 | 94 | let resetButton = button({ textContent: 'reset', onclick: reset }) 95 | 96 | watch(() => { 97 | resetButton.disabled = aInput.valueAsNumber === 0 98 | }) 99 | 100 | function reset() { 101 | aInput.value = '0' 102 | } 103 | 104 | document.body.appendChild( 105 | fragment([ 106 | h2({ textContent: 'input event demo' }), 107 | aInput, 108 | ' + ', 109 | bInput, 110 | ' = ', 111 | cInput, 112 | br(), 113 | aText, 114 | ' + ', 115 | bText, 116 | ' = ', 117 | cText, 118 | br(), 119 | resetButton, 120 | ]), 121 | ) 122 | 123 | document.body.appendChild( 124 | fragment([ 125 | h2({ textContent: 'more demo' }), 126 | a({ href: 'signup.html', textContent: 'signup.html' }), 127 | ', ', 128 | a({ href: 'hybrid.html', textContent: 'hybrid.html' }), 129 | ', ', 130 | a({ href: 'style.html', textContent: 'style.html' }), 131 | ]), 132 | ) 133 | 134 | console.timeEnd('init') 135 | -------------------------------------------------------------------------------- /demo/quick-example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Quick Example 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /demo/quick-example.ts: -------------------------------------------------------------------------------- 1 | import { fragment, text, watch } from '../core' 2 | import { input, label, p } from '../helpers' 3 | 4 | let nameInput = input({ placeholder: 'guest', id: 'visitor-name' }) 5 | let nameText = text() 6 | 7 | // auto re-run when the value in changed 8 | watch(() => { 9 | nameText.textContent = nameInput.value || nameInput.placeholder 10 | }) 11 | 12 | document.body.appendChild( 13 | fragment([ 14 | label({ textContent: 'name: ', htmlFor: nameInput.id }), 15 | nameInput, 16 | p(['hello, ', nameText]), 17 | ]), 18 | ) 19 | -------------------------------------------------------------------------------- /demo/signup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | signup form demo 8 | 9 | 10 | 11 |

Sign-up Form Demo

12 |

13 | The form in this page is created in Typescript with dom-proxy's creation 14 | helper functions. 15 |

16 |

17 | If you want to work with existing elements that are already in the DOM, 18 | see hybrid.html 19 |

20 |

Demo Form

21 | 22 | 23 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /demo/signup.ts: -------------------------------------------------------------------------------- 1 | import { appendChild, text, watch } from '../core' 2 | import { div, form, input, label, p, br, img, select, option } from '../helpers' 3 | import { selectImage } from '@beenotung/tslib/file' 4 | import { compressMobilePhoto } from '@beenotung/tslib/image' 5 | 6 | let roleSelect = select({ id: 'role', value: 'student' }, [ 7 | option({ value: 'parent', text: 'Parent' }), 8 | option({ value: 'teacher', text: 'Teacher' }), 9 | option({ value: 'student', text: 'Student' }), 10 | ]) 11 | let usernameInput = input({ id: 'username' }) 12 | let passwordInput = input({ id: 'password', type: 'password' }) 13 | let confirmPasswordInput = input({ id: 'confirm-password', type: 'password' }) 14 | let avatarInput = input({ 15 | id: 'avatar', 16 | type: 'button', 17 | value: 'choose an image', 18 | onclick: selectAvatar, 19 | }) 20 | let avatarImg = img({ alt: 'avatar preview' }) 21 | avatarImg.style.maxWidth = '100%' 22 | avatarImg.style.maxHeight = '50vh' 23 | 24 | let previewText = text() 25 | 26 | let isValid = false 27 | 28 | watch(() => { 29 | previewText.textContent = 30 | `(${roleSelect.value}) ` + usernameInput.value + ':' + passwordInput.value 31 | }) 32 | 33 | watch(() => { 34 | isValid = passwordInput.value == confirmPasswordInput.value 35 | let color = isValid ? 'green' : 'red' 36 | confirmPasswordInput.style.outline = '3px solid ' + color 37 | }) 38 | 39 | function inputField(input: HTMLInputElement | HTMLSelectElement) { 40 | let inputFieldDiv = div({ className: 'input-field' }, [ 41 | label({ textContent: input.id + ': ', htmlFor: input.id }), 42 | br(), 43 | input, 44 | ]) 45 | inputFieldDiv.style.marginBottom = '0.5rem' 46 | input.style.marginTop = '0.25rem' 47 | return inputFieldDiv 48 | } 49 | 50 | async function selectAvatar() { 51 | console.log('selectAvatar') 52 | let [file] = await selectImage() 53 | if (!file) return 54 | let dataUrl = await compressMobilePhoto({ image: file }) 55 | avatarImg.src = dataUrl 56 | } 57 | 58 | function submitForm(event: Event) { 59 | event.preventDefault() 60 | if (!isValid) { 61 | alert('please correct the fields before submitting') 62 | return 63 | } 64 | alert('valid to submit') 65 | } 66 | 67 | let signupForm = form({ onsubmit: submitForm }, [ 68 | inputField(roleSelect), 69 | inputField(usernameInput), 70 | inputField(avatarInput), 71 | avatarImg, 72 | inputField(passwordInput), 73 | inputField(confirmPasswordInput), 74 | p(['preview: ', previewText]), 75 | input({ type: 'submit', value: 'Sign Up' }), 76 | ]) 77 | 78 | appendChild(document.body, signupForm) 79 | -------------------------------------------------------------------------------- /demo/style.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | style demo 8 | 9 | 17 | 18 | 19 |

Style Demo

20 |

This demo inline styling and setting className

21 | 22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /demo/style.ts: -------------------------------------------------------------------------------- 1 | import { text, watch } from '../core' 2 | import { div } from '../helpers' 3 | 4 | let timeText = text(0) 5 | let unitText = text() 6 | let classText = text() 7 | 8 | let box = div( 9 | { 10 | style: { 11 | maxWidth: 'fit-content', 12 | outline: '1px solid black', 13 | padding: '0.5rem', 14 | }, 15 | }, 16 | [timeText, ' ', unitText, ' (class: ', classText, ')'], 17 | ) 18 | 19 | let startTime = Date.now() 20 | 21 | setInterval(() => { 22 | let t = Math.round((Date.now() - startTime) / 1000) 23 | timeText.textContent = String(t) 24 | }, 1000) 25 | 26 | watch(() => { 27 | box.className = +timeText.textContent! % 2 === 0 ? 'even' : 'odd' 28 | }) 29 | 30 | watch(() => { 31 | classText.textContent = box.className 32 | }) 33 | 34 | document.body.appendChild(box.node) 35 | -------------------------------------------------------------------------------- /helpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | genCreateHTMLElement, 3 | genCreateSVGElement, 4 | PartialCreateElement, 5 | } from './core' 6 | 7 | type CreateHTMLElementFunctions = { 8 | [K in keyof HTMLElementTagNameMap]: PartialCreateElement< 9 | HTMLElementTagNameMap[K] 10 | > 11 | } 12 | 13 | export const createHTMLElementFunctions = new Proxy( 14 | {} as CreateHTMLElementFunctions, 15 | { 16 | get(target, p: keyof HTMLElementTagNameMap, receiver) { 17 | return genCreateHTMLElement(p) 18 | }, 19 | }, 20 | ) 21 | 22 | type CreateSVGElementFunctions = { 23 | [K in keyof SVGElementTagNameMap]: PartialCreateElement< 24 | SVGElementTagNameMap[K] 25 | > 26 | } 27 | 28 | export const createSVGElementFunctions = new Proxy( 29 | {} as CreateSVGElementFunctions, 30 | { 31 | get(target, p: keyof SVGElementTagNameMap, receiver) { 32 | return genCreateSVGElement(p) 33 | }, 34 | }, 35 | ) 36 | 37 | export const createElementFunctions = { 38 | html: createHTMLElementFunctions, 39 | svg: createSVGElementFunctions, 40 | } 41 | 42 | export const { 43 | a, 44 | abbr, 45 | address, 46 | area, 47 | article, 48 | aside, 49 | audio, 50 | b, 51 | base, 52 | bdi, 53 | bdo, 54 | blockquote, 55 | body, 56 | br, 57 | button, 58 | canvas, 59 | caption, 60 | cite, 61 | code, 62 | col, 63 | colgroup, 64 | data, 65 | datalist, 66 | dd, 67 | del, 68 | details, 69 | dfn, 70 | dialog, 71 | div, 72 | dl, 73 | dt, 74 | em, 75 | embed, 76 | figcaption, 77 | figure, 78 | footer, 79 | form, 80 | h1, 81 | h2, 82 | h3, 83 | h4, 84 | h5, 85 | h6, 86 | head, 87 | header, 88 | hgroup, 89 | hr, 90 | html: htmlElement, 91 | i, 92 | iframe, 93 | img, 94 | input, 95 | ins, 96 | kbd, 97 | label, 98 | legend, 99 | li, 100 | link, 101 | main, 102 | map, 103 | mark, 104 | menu, 105 | meta, 106 | meter, 107 | nav, 108 | noscript, 109 | object, 110 | ol, 111 | optgroup, 112 | option, 113 | output, 114 | p, 115 | picture, 116 | pre, 117 | progress, 118 | q, 119 | rp, 120 | rt, 121 | ruby, 122 | s: sElement, 123 | samp, 124 | script: scriptElement, 125 | section, 126 | select, 127 | slot, 128 | small, 129 | source, 130 | span, 131 | strong, 132 | style: styleElement, 133 | sub, 134 | summary, 135 | sup, 136 | table, 137 | tbody, 138 | td, 139 | template, 140 | textarea, 141 | tfoot, 142 | th, 143 | thead, 144 | time, 145 | title: titleElement, 146 | tr, 147 | track, 148 | u, 149 | ul, 150 | var: varElement, 151 | video, 152 | wbr, 153 | } = createHTMLElementFunctions 154 | 155 | export const { 156 | a: aSVG, 157 | animate, 158 | animateMotion, 159 | animateTransform, 160 | circle, 161 | clipPath, 162 | defs, 163 | desc, 164 | ellipse, 165 | feBlend, 166 | feColorMatrix, 167 | feComponentTransfer, 168 | feComposite, 169 | feConvolveMatrix, 170 | feDiffuseLighting, 171 | feDisplacementMap, 172 | feDistantLight, 173 | feDropShadow, 174 | feFlood, 175 | feFuncA, 176 | feFuncB, 177 | feFuncG, 178 | feFuncR, 179 | feGaussianBlur, 180 | feImage, 181 | feMerge, 182 | feMergeNode, 183 | feMorphology, 184 | feOffset, 185 | fePointLight, 186 | feSpecularLighting, 187 | feSpotLight, 188 | feTile, 189 | feTurbulence, 190 | filter, 191 | foreignObject, 192 | g, 193 | image, 194 | line, 195 | linearGradient, 196 | marker, 197 | mask, 198 | metadata, 199 | mpath, 200 | path, 201 | pattern, 202 | polygon, 203 | polyline, 204 | radialGradient, 205 | rect, 206 | script: scriptSVG, 207 | set, 208 | stop, 209 | style: styleSVG, 210 | svg: svgSVG, 211 | switch: switchSVG, 212 | symbol, 213 | text: textSVG, 214 | textPath, 215 | title: titleSVG, 216 | tspan, 217 | use, 218 | view, 219 | } = createSVGElementFunctions 220 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './core' 2 | export * from './helpers' 3 | export * from './selector' 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dom-proxy", 3 | "version": "2.3.0", 4 | "description": "Develop declarative UI with (opt-in) automatic dependency tracking without boilerplate code, VDOM, nor compiler.", 5 | "keywords": [ 6 | "DOM", 7 | "proxy", 8 | "reactive", 9 | "declarative", 10 | "dependency tracking", 11 | "state management", 12 | "typescript", 13 | "lightweight" 14 | ], 15 | "author": "Beeno Tung (https://beeno-tung.surge.sh)", 16 | "license": "BSD-2-Clause", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/beenotung/dom-proxy.git" 20 | }, 21 | "homepage": "https://github.com/beenotung/dom-proxy#readme", 22 | "bugs": { 23 | "url": "https://github.com/beenotung/dom-proxy/issues" 24 | }, 25 | "main": "index.js", 26 | "types": "./index.d.ts", 27 | "files": [ 28 | "*.js", 29 | "*.d.ts", 30 | "demo" 31 | ], 32 | "scripts": { 33 | "demo": "run-p demo:*", 34 | "demo:quick-example": "esbuild --bundle demo/quick-example.ts --outfile=demo/quick-example.js", 35 | "demo:index": "esbuild --bundle demo/index.ts --outfile=demo/index.js", 36 | "demo:signup": "esbuild --bundle demo/signup.ts --outfile=demo/signup.js", 37 | "demo:hybrid": "esbuild --bundle demo/hybrid.ts --outfile=demo/hybrid.js", 38 | "demo:style": "esbuild --bundle demo/style.ts --outfile=demo/style.js", 39 | "dev": "npm run demo:quick-example -- --watch", 40 | "upload": "npm run demo && surge demo https://dom-proxy.surge.sh", 41 | "format": "prettier --write . && format-json-cli", 42 | "test": "tsc --noEmit", 43 | "clean": "rimraf *.js *.d.ts *.tsbuildinfo demo/*.js", 44 | "build": "npm run tsc && npm run esbuild", 45 | "tsc": "npm run clean && tsc -p .", 46 | "esbuild": "esbuild --bundle browser.ts --outfile=browser.js" 47 | }, 48 | "devDependencies": { 49 | "@beenotung/tslib": "^24.2.1", 50 | "@types/node": "^18.14.5", 51 | "esbuild": "^0.17.10", 52 | "format-json-cli": "^1.0.1", 53 | "npm-run-all": "^4.1.5", 54 | "prettier": "^2.8.7", 55 | "rimraf": "^5.0.0", 56 | "surge": "^0.23.1", 57 | "typescript": "^4.9.5" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /selector.ts: -------------------------------------------------------------------------------- 1 | import { createProxy, ProxyNode } from './core' 2 | 3 | /** @throws Error if the selector doesn't match any element */ 4 | export function queryElement( 5 | selector: Selector, 6 | parent: ParentNode = document.body, 7 | ) { 8 | let element = parent.querySelector>(selector) 9 | if (!element) throw new Error('failed to find element, selector: ' + selector) 10 | return element 11 | } 12 | 13 | /** @throws Error if the selector doesn't match any element */ 14 | export function queryElementProxy( 15 | selector: Selector, 16 | parent?: ParentNode, 17 | ) { 18 | return createProxy(queryElement(selector, parent)) 19 | } 20 | 21 | export function queryAllElements( 22 | selector: Selector, 23 | parent: ParentNode = document.body, 24 | ) { 25 | let elements = parent.querySelectorAll>(selector) 26 | return Array.from(elements) 27 | } 28 | 29 | export function queryAllElementProxies( 30 | selector: Selector, 31 | parent: ParentNode = document.body, 32 | ) { 33 | let elements = parent.querySelectorAll>(selector) 34 | return Array.from(elements, element => createProxy(element)) 35 | } 36 | 37 | /** @throws Error if any selectors don't match any elements */ 38 | export function queryElements< 39 | SelectorDict extends Dict, 40 | Selector extends string, 41 | >( 42 | selectors: SelectorDict, 43 | parent: ParentNode = document.body, 44 | ): { [P in keyof SelectorDict]: SelectorElement } { 45 | let object: any = {} 46 | for (let [key, selector] of Object.entries(selectors)) { 47 | object[key] = queryElement(selector, parent) 48 | } 49 | return object 50 | } 51 | 52 | /** @throws Error if any selectors don't match any elements */ 53 | export function queryElementProxies< 54 | SelectorDict extends Dict, 55 | Selector extends string, 56 | >( 57 | selectors: SelectorDict, 58 | parent: ParentNode = document.body, 59 | ): { [P in keyof SelectorDict]: ProxyNode> } { 60 | let object: any = {} 61 | for (let [key, selector] of Object.entries(selectors)) { 62 | object[key] = queryElementProxy(selector, parent) 63 | } 64 | return object 65 | } 66 | 67 | type SelectorElement = 68 | GetTagName extends `${infer TagName}` 69 | ? TagName extends keyof HTMLElementTagNameMap 70 | ? HTMLElementTagNameMap[TagName] 71 | : TagName extends keyof SVGElementTagNameMap 72 | ? SVGElementTagNameMap[TagName] 73 | : FallbackSelectorElement 74 | : FallbackSelectorElement 75 | 76 | type FallbackSelectorElement = 77 | Selector extends `${string}[name=${string}]${string}` 78 | ? HTMLInputElement 79 | : Element 80 | 81 | type Dict = { 82 | [key: string]: T 83 | } 84 | 85 | type RemoveTail< 86 | S extends String, 87 | Tail extends string, 88 | > = S extends `${infer Rest}${Tail}` ? Rest : S 89 | 90 | type RemoveHead< 91 | S extends String, 92 | Head extends string, 93 | > = S extends `${Head}${infer Rest}` ? Rest : S 94 | 95 | type GetTagName = RemoveTail< 96 | RemoveTail< 97 | RemoveTail< 98 | RemoveTail< 99 | RemoveHead, `${string}>`>, 100 | `:${string}` 101 | >, 102 | `[${string}` 103 | >, 104 | `.${string}` 105 | >, 106 | `#${string}` 107 | > 108 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "incremental": true 11 | }, 12 | "include": [ 13 | "index.ts" 14 | ], 15 | "exclude": [ 16 | "demo" 17 | ] 18 | } 19 | --------------------------------------------------------------------------------