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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
43 |
44 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | [](https://www.npmjs.com/package/dom-proxy)
6 | [](https://bundlephobia.com/package/dom-proxy)
7 | [](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 |
--------------------------------------------------------------------------------