@jsweb/ui 1.2.7 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/parser.ts ADDED
@@ -0,0 +1,480 @@
1
+ import { effect, reactive } from './reactivity'
2
+ import { evaluate, evaluateEvent } from './evaluator'
3
+
4
+ export type Context = Record<string, any>
5
+
6
+ interface BoundNode extends Node {
7
+ _effects?: Array<() => void>
8
+ }
9
+
10
+ export function cleanupTree(node: Node) {
11
+ const bNode = node as BoundNode
12
+ if (bNode._effects) {
13
+ bNode._effects.forEach((stop) => stop())
14
+ bNode._effects = []
15
+ }
16
+ const children = Array.from(node.childNodes)
17
+ for (const child of children) cleanupTree(child)
18
+ }
19
+
20
+ export function createContext(scope: any, context: Context = {}): Context {
21
+ const reactiveScope = scope._isReactive ? scope : reactive(scope)
22
+
23
+ return new Proxy(reactiveScope, {
24
+ get(target, prop) {
25
+ if (prop === '_isContext') return true
26
+ if (prop in target) return Reflect.get(target, prop, target)
27
+ if (prop in context) {
28
+ return Reflect.get(context, prop, context)
29
+ }
30
+ return Reflect.get(target, prop, target)
31
+ },
32
+ set(target, prop, value) {
33
+ if (prop in target) return Reflect.set(target, prop, value, target)
34
+ if (prop in context) {
35
+ return Reflect.set(context, prop, value, context)
36
+ }
37
+ return Reflect.set(target, prop, value, target)
38
+ },
39
+ has(target, prop) {
40
+ if (prop in target) return true
41
+ if (prop in context) return true
42
+ return false
43
+ },
44
+ })
45
+ }
46
+
47
+ export function parseNode(node: Node, context: Context) {
48
+ if (node.nodeType !== Node.ELEMENT_NODE) return
49
+
50
+ const el = node as HTMLElement
51
+ const scope = processScope(el, context)
52
+ if (!scope) return
53
+
54
+ const forAttrs = ['ui:for', ':for']
55
+ const forDirective = getDirectiveValue(el, forAttrs)
56
+ if (forDirective) {
57
+ removeDirectiveAttributes(el, forAttrs)
58
+ processFor(el, forDirective, scope)
59
+ return
60
+ }
61
+
62
+ const ifAttrs = ['ui:if', ':if']
63
+ const ifDirective = getDirectiveValue(el, ifAttrs)
64
+ if (ifDirective) {
65
+ removeDirectiveAttributes(el, ifAttrs)
66
+ processIf(el, ifDirective, scope)
67
+ }
68
+
69
+ processAttributes(el, scope)
70
+
71
+ const children = Array.from(el.childNodes)
72
+ for (const child of children) parseNode(child, scope)
73
+ }
74
+
75
+ export function createScope(
76
+ selectorOrElement: string | HTMLElement,
77
+ context: Context = {},
78
+ ) {
79
+ const el =
80
+ typeof selectorOrElement === 'string'
81
+ ? document.querySelector(selectorOrElement)
82
+ : selectorOrElement
83
+
84
+ if (el) {
85
+ if (!context.$emit) {
86
+ context.$emit = (eventName: string, detail?: any) => {
87
+ el.dispatchEvent(
88
+ new CustomEvent(eventName, { detail, bubbles: true, composed: true }),
89
+ )
90
+ }
91
+ }
92
+
93
+ if (!context.$refs) {
94
+ context.$refs = new Map<string, any>()
95
+ }
96
+
97
+ parseNode(el, context)
98
+ } else {
99
+ console.warn('[jsweb/ui] Element not found:', selectorOrElement)
100
+ }
101
+ }
102
+
103
+ function bindEffect(node: Node, fn: () => void) {
104
+ const e = effect(fn)
105
+ const bNode = node as BoundNode
106
+ bNode._effects ??= []
107
+ bNode._effects.push(e.stop)
108
+ }
109
+
110
+ function getDirectiveValue(el: HTMLElement, names: string[]) {
111
+ for (const name of names) {
112
+ const value = el.getAttribute(name)
113
+ if (value !== null) return value
114
+ }
115
+ return null
116
+ }
117
+
118
+ function removeDirectiveAttributes(el: HTMLElement, names: string[]) {
119
+ for (const name of names) {
120
+ el.removeAttribute(name)
121
+ }
122
+ }
123
+
124
+ function processScope(el: HTMLElement, context: Context) {
125
+ const attrs = ['ui:scope', ':scope']
126
+ const directive = getDirectiveValue(el, attrs)
127
+ if (!directive) return context
128
+
129
+ const scope = evaluate(directive, context)
130
+ if (!scope) return undefined
131
+
132
+ removeDirectiveAttributes(el, attrs)
133
+
134
+ if (!scope.$emit) {
135
+ scope.$emit = (event: string, detail?: any) => {
136
+ el.dispatchEvent(
137
+ new CustomEvent(event, { detail, bubbles: true, composed: true }),
138
+ )
139
+ }
140
+ }
141
+
142
+ if (!scope.$refs) {
143
+ scope.$refs = context.$refs ?? new Map<string, any>()
144
+ }
145
+
146
+ return createContext(scope, context)
147
+ }
148
+
149
+ function processFor(el: HTMLElement, expr: string, context: Context) {
150
+ const parent = el.parentNode
151
+ if (!parent) return
152
+
153
+ const match = /^\s*(.+)\s+(?:in|of)\s+(.+)\s*$/.exec(expr)
154
+ if (!match) {
155
+ return console.warn(`[jsweb/ui] Invalid ui:for expression: ${expr}`)
156
+ }
157
+ const [, itemName, listName] = match
158
+
159
+ const keyAttr = ['ui:key', ':key']
160
+ const keyDirective = getDirectiveValue(el, keyAttr)
161
+ removeDirectiveAttributes(el, keyAttr)
162
+
163
+ const uuid = crypto.randomUUID()
164
+ const comment = document.createComment(` ui:for ${uuid} `)
165
+ el.replaceWith(comment)
166
+
167
+ interface RenderedNode {
168
+ key: any
169
+ el: HTMLElement
170
+ scope: any
171
+ }
172
+ let renderedNodes: RenderedNode[] = []
173
+
174
+ bindEffect(comment, () => {
175
+ const list = evaluate(listName, context)
176
+
177
+ if (!Array.isArray(list)) {
178
+ renderedNodes.forEach((node) => {
179
+ node.el.remove()
180
+ cleanupTree(node.el)
181
+ })
182
+ renderedNodes = []
183
+ return
184
+ }
185
+
186
+ const newNodes: RenderedNode[] = []
187
+ const oldNodesByKey = new Map<any, RenderedNode>()
188
+ renderedNodes.forEach((node) => oldNodesByKey.set(node.key, node))
189
+
190
+ list.forEach((item, index) => {
191
+ let key: any = index
192
+
193
+ if (keyDirective) {
194
+ const tempContext = createContext(
195
+ { [itemName]: item, $index: index },
196
+ context,
197
+ )
198
+ key = evaluate(keyDirective, tempContext)
199
+ }
200
+
201
+ const scope = { [itemName]: item, $index: index, $key: key }
202
+
203
+ let node = oldNodesByKey.get(key)
204
+ if (node) {
205
+ // Reuse node
206
+ node.scope[itemName] = item
207
+ node.scope.$index = index
208
+ node.scope.$key = key
209
+ oldNodesByKey.delete(key)
210
+ } else {
211
+ // Create new node
212
+ const clone = el.cloneNode(true) as HTMLElement
213
+ const reactiveScope = reactive(scope)
214
+ const localContext = createContext(reactiveScope, context)
215
+ parseNode(clone, localContext)
216
+ node = { key, el: clone, scope: reactiveScope }
217
+ }
218
+
219
+ newNodes.push(node)
220
+ })
221
+
222
+ // Remove un-reused nodes
223
+ oldNodesByKey.forEach((node) => {
224
+ node.el.remove()
225
+ cleanupTree(node.el)
226
+ })
227
+
228
+ // Reorder and insert new DOM nodes
229
+ let currentAnchor = comment.nextSibling
230
+ newNodes.forEach((node) => {
231
+ if (currentAnchor === node.el) {
232
+ currentAnchor = currentAnchor.nextSibling
233
+ } else {
234
+ comment.parentNode?.insertBefore(node.el, currentAnchor)
235
+ }
236
+ })
237
+
238
+ renderedNodes = newNodes
239
+ })
240
+ }
241
+
242
+ function processIf(el: HTMLElement, expr: string, context: Context) {
243
+ const parent = el.parentNode
244
+ if (!parent) return
245
+
246
+ const uuid = crypto.randomUUID()
247
+ const comment = document.createComment(` ui:if ${uuid} `)
248
+ el.before(comment)
249
+
250
+ bindEffect(comment, () => {
251
+ const val = evaluate(expr, context)
252
+ if (val) {
253
+ if (!el.parentNode) {
254
+ comment.parentNode?.insertBefore(el, comment.nextSibling)
255
+ }
256
+ } else if (el.parentNode) {
257
+ el.remove()
258
+ }
259
+ })
260
+ }
261
+
262
+ function processAttributes(el: HTMLElement, context: Context) {
263
+ const attrs = Array.from(el.attributes)
264
+
265
+ for (const attr of attrs) {
266
+ const { name, value } = attr
267
+ const isText = ['ui:text', ':text'].includes(name)
268
+ const isTwoWayBind = ['ui:bind', ':bind'].includes(name)
269
+ const isClassBind = ['ui:class', ':class'].includes(name)
270
+ const isStyleBind = ['ui:style', ':style'].includes(name)
271
+ const isRef = ['ui:ref', ':ref'].includes(name)
272
+ const isAttrBind = name.startsWith('ui:') || name.startsWith(':')
273
+ const isEvent = name.startsWith('ui@') || name.startsWith('@')
274
+
275
+ if (isText) {
276
+ processTextBinding(el, value, context)
277
+ el.removeAttribute(name)
278
+ } else if (isTwoWayBind) {
279
+ processTwoWayBinding(el, value, context)
280
+ el.removeAttribute(name)
281
+ } else if (isClassBind) {
282
+ processClassBinding(el, value, context)
283
+ el.removeAttribute(name)
284
+ } else if (isStyleBind) {
285
+ processStyleBinding(el, value, context)
286
+ el.removeAttribute(name)
287
+ } else if (isRef) {
288
+ processRef(el, value, context)
289
+ el.removeAttribute(name)
290
+ } else if (isAttrBind) {
291
+ const bound = name.split(':').pop()!
292
+ processAttrBinding(el, bound, value, context)
293
+ el.removeAttribute(name)
294
+ } else if (isEvent) {
295
+ processEventBinding(el, name, value, context)
296
+ el.removeAttribute(name)
297
+ }
298
+ }
299
+ }
300
+
301
+ function processRef(el: HTMLElement, expr: string, context: Context) {
302
+ const refName = expr.trim().replace(/^['"]|['"]$/g, '')
303
+ if (!refName) return
304
+
305
+ const refs = context.$refs as Map<string, any>
306
+ if (!refs) return
307
+
308
+ const key = context.$key
309
+
310
+ if (key !== undefined) {
311
+ let group = refs.get(refName)
312
+ if (!(group instanceof Map)) {
313
+ group = new Map<any, HTMLElement>()
314
+ refs.set(refName, group)
315
+ }
316
+ group.set(key, el)
317
+ } else {
318
+ refs.set(refName, el)
319
+ }
320
+
321
+ const bNode = el as BoundNode
322
+ bNode._effects ??= []
323
+ bNode._effects.push(() => {
324
+ if (key !== undefined) {
325
+ const group = refs.get(refName)
326
+ if (group instanceof Map) {
327
+ group.delete(key)
328
+ if (group.size === 0) {
329
+ refs.delete(refName)
330
+ }
331
+ }
332
+ } else if (refs.get(refName) === el) {
333
+ refs.delete(refName)
334
+ }
335
+ })
336
+ }
337
+
338
+ function processTextBinding(el: HTMLElement, expr: string, context: Context) {
339
+ bindEffect(el, () => {
340
+ const val = evaluate(expr, context)
341
+ el.textContent = val !== undefined && val !== null ? String(val) : ''
342
+ })
343
+ }
344
+
345
+ function processTwoWayBinding(el: HTMLElement, expr: string, context: Context) {
346
+ const isCheckbox = el instanceof HTMLInputElement && el.type === 'checkbox'
347
+ const isRadio = el instanceof HTMLInputElement && el.type === 'radio'
348
+
349
+ // 1. Reactive state to DOM
350
+ bindEffect(el, () => {
351
+ const val = evaluate(expr, context)
352
+ if (isCheckbox) {
353
+ el.checked = !!val
354
+ } else if (isRadio) {
355
+ el.checked = el.value === String(val)
356
+ } else {
357
+ const target = el as
358
+ | HTMLInputElement
359
+ | HTMLSelectElement
360
+ | HTMLTextAreaElement
361
+ target.value = val == null ? '' : String(val)
362
+ }
363
+ })
364
+
365
+ // 2. DOM to Reactive state
366
+ const isChange = isCheckbox || isRadio || el instanceof HTMLSelectElement
367
+ const eventName = isChange ? 'change' : 'input'
368
+ el.addEventListener(eventName, ($event) => {
369
+ const target = isCheckbox ? 'checked' : 'value'
370
+ const value = `$event.target.${target}`
371
+ evaluateEvent($event, `${expr} = ${value}`, context)
372
+ })
373
+ }
374
+
375
+ function processAttrBinding(
376
+ el: HTMLElement,
377
+ attr: string,
378
+ expr: string,
379
+ context: Context,
380
+ ) {
381
+ bindEffect(el, () => {
382
+ const val = evaluate(expr, context)
383
+ if (val === null || val === undefined || val === false) {
384
+ el.removeAttribute(attr)
385
+ } else if (val === true) {
386
+ el.setAttribute(attr, '')
387
+ } else {
388
+ el.setAttribute(attr, String(val))
389
+ }
390
+ })
391
+ }
392
+
393
+ function processClassBinding(el: HTMLElement, expr: string, context: Context) {
394
+ let oldClasses = new Set<string>()
395
+
396
+ bindEffect(el, () => {
397
+ const val = evaluate(expr, context)
398
+ const newClasses = new Set<string>()
399
+ const addClass = (c: string) => c && newClasses.add(c)
400
+ const addClasses = (c: string) => c.split(/\s+/).forEach(addClass)
401
+
402
+ if (typeof val === 'string') addClasses(val)
403
+ else if (Array.isArray(val)) {
404
+ val.flat().forEach((c: any) => {
405
+ if (typeof c === 'string') addClasses(c)
406
+ })
407
+ } else if (typeof val === 'object' && val !== null) {
408
+ Object.entries(val).forEach(([c, condition]: [string, any]) => {
409
+ if (condition) addClasses(c)
410
+ })
411
+ }
412
+
413
+ oldClasses.forEach((c) => {
414
+ if (!newClasses.has(c)) el.classList.remove(c)
415
+ })
416
+ newClasses.forEach((c) => {
417
+ if (!oldClasses.has(c)) el.classList.add(c)
418
+ })
419
+
420
+ oldClasses = newClasses
421
+ })
422
+ }
423
+
424
+ function processStyleBinding(el: HTMLElement, expr: string, context: Context) {
425
+ let oldStyles: Record<string, any> = {}
426
+
427
+ bindEffect(el, () => {
428
+ const val = evaluate(expr, context)
429
+ const newStyles = typeof val === 'object' && val !== null ? val : {}
430
+
431
+ for (const key in oldStyles) {
432
+ if (!(key in newStyles)) {
433
+ ;(el.style as any)[key] = ''
434
+ }
435
+ }
436
+
437
+ for (const key in newStyles) {
438
+ if (oldStyles[key] !== newStyles[key]) {
439
+ ;(el.style as any)[key] = newStyles[key]
440
+ }
441
+ }
442
+
443
+ oldStyles = { ...newStyles }
444
+ })
445
+ }
446
+
447
+ function processEventBinding(
448
+ el: HTMLElement,
449
+ evt: string,
450
+ expr: string,
451
+ context: Context,
452
+ ) {
453
+ const refs = evt.split('@').pop()!
454
+ const [name, ...modifiers] = refs.split('.')
455
+
456
+ const isOutside = modifiers.includes('outside')
457
+ const target = isOutside ? document : el
458
+
459
+ const handler: EventListener = ($event: Event) => {
460
+ if (!el.isConnected) return
461
+
462
+ const isTargetNode = $event.target instanceof Node
463
+
464
+ if (isOutside && isTargetNode && el.contains($event.target)) return
465
+ if (modifiers.includes('self') && $event.target !== el) return
466
+
467
+ if (modifiers.includes('prevent')) $event.preventDefault()
468
+ if (modifiers.includes('stop')) $event.stopPropagation()
469
+
470
+ evaluateEvent($event, expr, context)
471
+ }
472
+
473
+ target.addEventListener(name, handler)
474
+
475
+ if (isOutside) {
476
+ const bNode = el as BoundNode
477
+ bNode._effects ??= []
478
+ bNode._effects.push(() => target.removeEventListener(name, handler))
479
+ }
480
+ }
@@ -0,0 +1,185 @@
1
+ let activeEffect: symbol | null = null
2
+ const targetMap = new WeakMap<
3
+ object,
4
+ Map<string | symbol, Set<ReactiveEffect>>
5
+ >()
6
+ const proxyMap = new WeakMap<object, any>()
7
+ const effectMap = new WeakMap<symbol, ReactiveEffect>()
8
+
9
+ export class ReactiveEffect {
10
+ active = true
11
+ deps: Set<Set<ReactiveEffect>> = new Set()
12
+
13
+ constructor(public fn: () => void) {}
14
+
15
+ run() {
16
+ if (!this.active) return this.fn()
17
+
18
+ this.cleanup()
19
+
20
+ activeEffect = Symbol()
21
+ effectMap.set(activeEffect, this)
22
+
23
+ try {
24
+ return this.fn()
25
+ } finally {
26
+ effectMap.delete(activeEffect)
27
+ activeEffect = null
28
+ }
29
+ }
30
+
31
+ stop() {
32
+ if (this.active) {
33
+ this.cleanup()
34
+ this.active = false
35
+ }
36
+ }
37
+
38
+ cleanup() {
39
+ this.deps.forEach((dep) => dep.delete(this))
40
+ this.deps.clear()
41
+ }
42
+
43
+ effect() {
44
+ return {
45
+ run: () => this.run(),
46
+ stop: () => this.stop(),
47
+ }
48
+ }
49
+ }
50
+
51
+ export function effect(fn: () => void) {
52
+ const ref = new ReactiveEffect(fn)
53
+ ref.run()
54
+ return ref.effect()
55
+ }
56
+
57
+ export function track(target: object, key: string | symbol) {
58
+ if (activeEffect) {
59
+ let depsMap = targetMap.get(target)
60
+ if (!depsMap) {
61
+ depsMap = new Map()
62
+ targetMap.set(target, depsMap)
63
+ }
64
+
65
+ let dep = depsMap.get(key)
66
+ if (!dep) {
67
+ dep = new Set()
68
+ depsMap.set(key, dep)
69
+ }
70
+
71
+ const active = effectMap.get(activeEffect)
72
+ if (active) {
73
+ dep.add(active)
74
+ active.deps.add(dep)
75
+ }
76
+ }
77
+ }
78
+
79
+ export function trigger(target: object, key: string | symbol) {
80
+ const depsMap = targetMap.get(target)
81
+ if (!depsMap) return
82
+
83
+ const dep = depsMap.get(key)
84
+ if (dep) {
85
+ const effects = new Set(dep)
86
+ effects.forEach((effect) => effect.run())
87
+ }
88
+ }
89
+
90
+ export function reactive<T extends object>(target: T): T {
91
+ const notObject = typeof target !== 'object' || target === null
92
+ if (notObject) return target
93
+
94
+ if (
95
+ target instanceof Map ||
96
+ target instanceof Set ||
97
+ target instanceof WeakMap ||
98
+ target instanceof WeakSet ||
99
+ target instanceof Date ||
100
+ target instanceof RegExp ||
101
+ (typeof Node === 'function' && target instanceof Node)
102
+ ) {
103
+ return target
104
+ }
105
+
106
+ const isReactive = Object.hasOwn(target, '_isReactive')
107
+ if (isReactive) return target
108
+
109
+ const existingProxy = proxyMap.get(target)
110
+ if (existingProxy) return existingProxy
111
+
112
+ const proxy = new Proxy(target, {
113
+ get(obj, key, receiver) {
114
+ if (key === '_isReactive') return true
115
+ track(obj, key)
116
+
117
+ const res = Reflect.get(obj, key, receiver)
118
+ // deep reactivity
119
+ return typeof res === 'object' && res !== null ? reactive(res) : res
120
+ },
121
+ set(obj, key, value, receiver) {
122
+ const isArray = Array.isArray(obj)
123
+ const oldValue = Reflect.get(obj, key, receiver)
124
+ const hadKey =
125
+ isArray && String(Number(key)) === key
126
+ ? Number(key) < obj.length
127
+ : Object.hasOwn(obj, key)
128
+
129
+ const result = Reflect.set(obj, key, value, receiver)
130
+
131
+ if (!hadKey) {
132
+ trigger(obj, key)
133
+ if (isArray && key !== 'length') {
134
+ trigger(obj, 'length')
135
+ }
136
+ } else if (oldValue !== value) {
137
+ trigger(obj, key)
138
+ }
139
+
140
+ return result
141
+ },
142
+ })
143
+
144
+ proxyMap.set(target, proxy)
145
+ return proxy
146
+ }
147
+
148
+ export function traverse(value: any, seen = new Set()) {
149
+ if (typeof value !== 'object' || value === null || seen.has(value)) {
150
+ return value
151
+ }
152
+ seen.add(value)
153
+ for (const key in value) {
154
+ traverse(value[key], seen)
155
+ }
156
+ return value
157
+ }
158
+
159
+ export function watch(
160
+ source: any | (() => any),
161
+ cb: (newValue: any, oldValue: any) => void,
162
+ options?: { immediate?: boolean },
163
+ ) {
164
+ let oldValue: any
165
+ let isFirstRun = true
166
+
167
+ const getter = source instanceof Function ? source : () => traverse(source)
168
+
169
+ const runner = effect(() => {
170
+ const newValue = getter()
171
+
172
+ if (isFirstRun) {
173
+ isFirstRun = false
174
+ oldValue = newValue
175
+ if (options?.immediate) {
176
+ cb(newValue, undefined)
177
+ }
178
+ } else {
179
+ cb(newValue, oldValue)
180
+ oldValue = newValue
181
+ }
182
+ })
183
+
184
+ return runner.stop
185
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "useDefineForClassFields": true,
5
+ "module": "ESNext",
6
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+
16
+ /* Linting */
17
+ "strict": true,
18
+ "noUnusedLocals": true,
19
+ "noUnusedParameters": true,
20
+ "noFallthroughCasesInSwitch": true
21
+ },
22
+ "include": ["src", "vite.config.ts"]
23
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from 'vite'
2
+ import dts from 'vite-plugin-dts'
3
+
4
+ export default defineConfig({
5
+ build: {
6
+ lib: {
7
+ name: '@jsweb/ui',
8
+ formats: ['es', 'umd'],
9
+ entry: './src/index.ts',
10
+ fileName: (format: string) => `index.${format}.js`,
11
+ },
12
+ sourcemap: true,
13
+ minify: 'terser',
14
+ },
15
+ plugins: [dts({ insertTypesEntry: true })],
16
+ })