<- "[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 |
--------------------------------------------------------------------------------