@jsweb/ui 1.3.1 → 1.3.3

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 DELETED
@@ -1,490 +0,0 @@
1
- import { effect, reactive, type ScopeContext } 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<T extends object = Context>(
76
- selectorOrElement: string | HTMLElement,
77
- context?: T & ThisType<T & ScopeContext>,
78
- ) {
79
- const el =
80
- typeof selectorOrElement === 'string'
81
- ? document.querySelector(selectorOrElement)
82
- : selectorOrElement
83
-
84
- if (el) {
85
- const ctx = (context ?? {}) as Context
86
- ctx.$el = el
87
-
88
- if (!ctx.$emit) {
89
- ctx.$emit = (eventName: string, detail?: any) => {
90
- el.dispatchEvent(
91
- new CustomEvent(eventName, { detail, bubbles: true, composed: true }),
92
- )
93
- }
94
- }
95
-
96
- if (!ctx.$refs) {
97
- ctx.$refs = new Map<string, any>()
98
- }
99
-
100
- parseNode(el, ctx)
101
- } else {
102
- console.warn('[jsweb/ui] Element not found:', selectorOrElement)
103
- }
104
- }
105
-
106
- function bindEffect(node: Node, fn: () => void) {
107
- const e = effect(fn)
108
- const bNode = node as BoundNode
109
- bNode._effects ??= []
110
- bNode._effects.push(e.stop)
111
- }
112
-
113
- function getDirectiveValue(el: HTMLElement, names: string[]) {
114
- for (const name of names) {
115
- const value = el.getAttribute(name)
116
- if (value !== null) return value
117
- }
118
- return null
119
- }
120
-
121
- function removeDirectiveAttributes(el: HTMLElement, names: string[]) {
122
- for (const name of names) {
123
- el.removeAttribute(name)
124
- }
125
- }
126
-
127
- function processScope(el: HTMLElement, context: Context) {
128
- const attrs = ['ui:scope', ':scope']
129
- const directive = getDirectiveValue(el, attrs)
130
- if (!directive) return context
131
-
132
- const scope = evaluate(directive, context)
133
- if (!scope) return undefined
134
-
135
- removeDirectiveAttributes(el, attrs)
136
-
137
- scope.$el = el
138
-
139
- if (!scope.$emit) {
140
- scope.$emit = (event: string, detail?: any) => {
141
- el.dispatchEvent(
142
- new CustomEvent(event, { detail, bubbles: true, composed: true }),
143
- )
144
- }
145
- }
146
-
147
- if (!scope.$refs) {
148
- scope.$refs = context.$refs ?? new Map<string, any>()
149
- }
150
-
151
- return createContext(scope, context)
152
- }
153
-
154
- function processFor(el: HTMLElement, expr: string, context: Context) {
155
- const parent = el.parentNode
156
- if (!parent) return
157
-
158
- const match = /^\s*(.+)\s+(?:in|of)\s+(.+)\s*$/.exec(expr)
159
- if (!match) {
160
- return console.warn(`[jsweb/ui] Invalid ui:for expression: ${expr}`)
161
- }
162
- const [, itemName, listName] = match
163
-
164
- const keyAttr = ['ui:key', ':key']
165
- const keyDirective = getDirectiveValue(el, keyAttr)
166
- removeDirectiveAttributes(el, keyAttr)
167
-
168
- const uuid = crypto.randomUUID()
169
- const comment = document.createComment(` ui:for ${uuid} `)
170
- el.replaceWith(comment)
171
-
172
- interface RenderedNode {
173
- key: any
174
- el: HTMLElement
175
- scope: any
176
- }
177
- let renderedNodes: RenderedNode[] = []
178
-
179
- bindEffect(comment, () => {
180
- const list = evaluate(listName, context)
181
-
182
- if (!Array.isArray(list)) {
183
- renderedNodes.forEach((node) => {
184
- node.el.remove()
185
- cleanupTree(node.el)
186
- })
187
- renderedNodes = []
188
- return
189
- }
190
-
191
- const newNodes: RenderedNode[] = []
192
- const oldNodesByKey = new Map<any, RenderedNode>()
193
- renderedNodes.forEach((node) => oldNodesByKey.set(node.key, node))
194
-
195
- list.forEach((item, index) => {
196
- let key: any = index
197
-
198
- if (keyDirective) {
199
- const tempContext = createContext(
200
- { [itemName]: item, $index: index },
201
- context,
202
- )
203
- key = evaluate(keyDirective, tempContext)
204
- }
205
-
206
- let node = oldNodesByKey.get(key)
207
- if (node) {
208
- // Reuse node
209
- node.scope[itemName] = item
210
- node.scope.$index = index
211
- node.scope.$key = key
212
- node.scope.$el = node.el
213
- oldNodesByKey.delete(key)
214
- } else {
215
- // Create new node
216
- const clone = el.cloneNode(true) as HTMLElement
217
- const scope = {
218
- [itemName]: item,
219
- $index: index,
220
- $key: key,
221
- $el: clone,
222
- }
223
- const reactiveScope = reactive(scope)
224
- const localContext = createContext(reactiveScope, context)
225
- parseNode(clone, localContext)
226
- node = { key, el: clone, scope: reactiveScope }
227
- }
228
-
229
- newNodes.push(node)
230
- })
231
-
232
- // Remove un-reused nodes
233
- oldNodesByKey.forEach((node) => {
234
- node.el.remove()
235
- cleanupTree(node.el)
236
- })
237
-
238
- // Reorder and insert new DOM nodes
239
- let currentAnchor = comment.nextSibling
240
- newNodes.forEach((node) => {
241
- if (currentAnchor === node.el) {
242
- currentAnchor = currentAnchor.nextSibling
243
- } else {
244
- comment.parentNode?.insertBefore(node.el, currentAnchor)
245
- }
246
- })
247
-
248
- renderedNodes = newNodes
249
- })
250
- }
251
-
252
- function processIf(el: HTMLElement, expr: string, context: Context) {
253
- const parent = el.parentNode
254
- if (!parent) return
255
-
256
- const uuid = crypto.randomUUID()
257
- const comment = document.createComment(` ui:if ${uuid} `)
258
- el.before(comment)
259
-
260
- bindEffect(comment, () => {
261
- const val = evaluate(expr, context)
262
- if (val) {
263
- if (!el.parentNode) {
264
- comment.parentNode?.insertBefore(el, comment.nextSibling)
265
- }
266
- } else if (el.parentNode) {
267
- el.remove()
268
- }
269
- })
270
- }
271
-
272
- function processAttributes(el: HTMLElement, context: Context) {
273
- const attrs = Array.from(el.attributes)
274
-
275
- for (const attr of attrs) {
276
- const { name, value } = attr
277
- const isText = ['ui:text', ':text'].includes(name)
278
- const isTwoWayBind = ['ui:bind', ':bind'].includes(name)
279
- const isClassBind = ['ui:class', ':class'].includes(name)
280
- const isStyleBind = ['ui:style', ':style'].includes(name)
281
- const isRef = ['ui:ref', ':ref'].includes(name)
282
- const isAttrBind = name.startsWith('ui:') || name.startsWith(':')
283
- const isEvent = name.startsWith('ui@') || name.startsWith('@')
284
-
285
- if (isText) {
286
- processTextBinding(el, value, context)
287
- el.removeAttribute(name)
288
- } else if (isTwoWayBind) {
289
- processTwoWayBinding(el, value, context)
290
- el.removeAttribute(name)
291
- } else if (isClassBind) {
292
- processClassBinding(el, value, context)
293
- el.removeAttribute(name)
294
- } else if (isStyleBind) {
295
- processStyleBinding(el, value, context)
296
- el.removeAttribute(name)
297
- } else if (isRef) {
298
- processRef(el, value, context)
299
- el.removeAttribute(name)
300
- } else if (isAttrBind) {
301
- const bound = name.split(':').pop()!
302
- processAttrBinding(el, bound, value, context)
303
- el.removeAttribute(name)
304
- } else if (isEvent) {
305
- processEventBinding(el, name, value, context)
306
- el.removeAttribute(name)
307
- }
308
- }
309
- }
310
-
311
- function processRef(el: HTMLElement, expr: string, context: Context) {
312
- const refName = expr.trim().replace(/^['"]|['"]$/g, '')
313
- if (!refName) return
314
-
315
- const refs = context.$refs as Map<string, any>
316
- if (!refs) return
317
-
318
- const key = context.$key
319
-
320
- if (key !== undefined) {
321
- let group = refs.get(refName)
322
- if (!(group instanceof Map)) {
323
- group = new Map<any, HTMLElement>()
324
- refs.set(refName, group)
325
- }
326
- group.set(key, el)
327
- } else {
328
- refs.set(refName, el)
329
- }
330
-
331
- const bNode = el as BoundNode
332
- bNode._effects ??= []
333
- bNode._effects.push(() => {
334
- if (key !== undefined) {
335
- const group = refs.get(refName)
336
- if (group instanceof Map) {
337
- group.delete(key)
338
- if (group.size === 0) {
339
- refs.delete(refName)
340
- }
341
- }
342
- } else if (refs.get(refName) === el) {
343
- refs.delete(refName)
344
- }
345
- })
346
- }
347
-
348
- function processTextBinding(el: HTMLElement, expr: string, context: Context) {
349
- bindEffect(el, () => {
350
- const val = evaluate(expr, context)
351
- el.textContent = val !== undefined && val !== null ? String(val) : ''
352
- })
353
- }
354
-
355
- function processTwoWayBinding(el: HTMLElement, expr: string, context: Context) {
356
- const isCheckbox = el instanceof HTMLInputElement && el.type === 'checkbox'
357
- const isRadio = el instanceof HTMLInputElement && el.type === 'radio'
358
-
359
- // 1. Reactive state to DOM
360
- bindEffect(el, () => {
361
- const val = evaluate(expr, context)
362
- if (isCheckbox) {
363
- el.checked = !!val
364
- } else if (isRadio) {
365
- el.checked = el.value === String(val)
366
- } else {
367
- const target = el as
368
- | HTMLInputElement
369
- | HTMLSelectElement
370
- | HTMLTextAreaElement
371
- target.value = val == null ? '' : String(val)
372
- }
373
- })
374
-
375
- // 2. DOM to Reactive state
376
- const isChange = isCheckbox || isRadio || el instanceof HTMLSelectElement
377
- const eventName = isChange ? 'change' : 'input'
378
- el.addEventListener(eventName, ($event) => {
379
- const target = isCheckbox ? 'checked' : 'value'
380
- const value = `$event.target.${target}`
381
- evaluateEvent($event, `${expr} = ${value}`, context)
382
- })
383
- }
384
-
385
- function processAttrBinding(
386
- el: HTMLElement,
387
- attr: string,
388
- expr: string,
389
- context: Context,
390
- ) {
391
- bindEffect(el, () => {
392
- const val = evaluate(expr, context)
393
- if (val === null || val === undefined || val === false) {
394
- el.removeAttribute(attr)
395
- } else if (val === true) {
396
- el.setAttribute(attr, '')
397
- } else {
398
- el.setAttribute(attr, String(val))
399
- }
400
- })
401
- }
402
-
403
- function processClassBinding(el: HTMLElement, expr: string, context: Context) {
404
- let oldClasses = new Set<string>()
405
-
406
- bindEffect(el, () => {
407
- const val = evaluate(expr, context)
408
- const newClasses = new Set<string>()
409
- const addClass = (c: string) => c && newClasses.add(c)
410
- const addClasses = (c: string) => c.split(/\s+/).forEach(addClass)
411
-
412
- if (typeof val === 'string') addClasses(val)
413
- else if (Array.isArray(val)) {
414
- val.flat().forEach((c: any) => {
415
- if (typeof c === 'string') addClasses(c)
416
- })
417
- } else if (typeof val === 'object' && val !== null) {
418
- Object.entries(val).forEach(([c, condition]: [string, any]) => {
419
- if (condition) addClasses(c)
420
- })
421
- }
422
-
423
- oldClasses.forEach((c) => {
424
- if (!newClasses.has(c)) el.classList.remove(c)
425
- })
426
- newClasses.forEach((c) => {
427
- if (!oldClasses.has(c)) el.classList.add(c)
428
- })
429
-
430
- oldClasses = newClasses
431
- })
432
- }
433
-
434
- function processStyleBinding(el: HTMLElement, expr: string, context: Context) {
435
- let oldStyles: Record<string, any> = {}
436
-
437
- bindEffect(el, () => {
438
- const val = evaluate(expr, context)
439
- const newStyles = typeof val === 'object' && val !== null ? val : {}
440
-
441
- for (const key in oldStyles) {
442
- if (!(key in newStyles)) {
443
- ;(el.style as any)[key] = ''
444
- }
445
- }
446
-
447
- for (const key in newStyles) {
448
- if (oldStyles[key] !== newStyles[key]) {
449
- ;(el.style as any)[key] = newStyles[key]
450
- }
451
- }
452
-
453
- oldStyles = { ...newStyles }
454
- })
455
- }
456
-
457
- function processEventBinding(
458
- el: HTMLElement,
459
- evt: string,
460
- expr: string,
461
- context: Context,
462
- ) {
463
- const refs = evt.split('@').pop()!
464
- const [name, ...modifiers] = refs.split('.')
465
-
466
- const isOutside = modifiers.includes('outside')
467
- const target = isOutside ? document : el
468
-
469
- const handler: EventListener = ($event: Event) => {
470
- if (!el.isConnected) return
471
-
472
- const isTargetNode = $event.target instanceof Node
473
-
474
- if (isOutside && isTargetNode && el.contains($event.target)) return
475
- if (modifiers.includes('self') && $event.target !== el) return
476
-
477
- if (modifiers.includes('prevent')) $event.preventDefault()
478
- if (modifiers.includes('stop')) $event.stopPropagation()
479
-
480
- evaluateEvent($event, expr, context)
481
- }
482
-
483
- target.addEventListener(name, handler)
484
-
485
- if (isOutside) {
486
- const bNode = el as BoundNode
487
- bNode._effects ??= []
488
- bNode._effects.push(() => target.removeEventListener(name, handler))
489
- }
490
- }
package/src/reactivity.ts DELETED
@@ -1,227 +0,0 @@
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 interface ScopeContext {
91
- /** Elemento DOM raiz associado ao escopo (somente leitura) */
92
- readonly $el: HTMLElement
93
- /** Map nativo indexando elementos referenciados via ui:ref / :ref (somente leitura) */
94
- readonly $refs: Map<string, any>
95
- /** Despacha CustomEvents nativos (bubbles: true, composed: true) */
96
- $emit: (event: string, detail?: any) => void
97
- /** Índice numérico da iteração atual em loops ui:for / :for */
98
- $index?: number
99
- /** Chave de identificação da iteração em loops ui:for / :for */
100
- $key?: any
101
- }
102
-
103
- export class Scope {
104
- /** Elemento DOM raiz ao qual o escopo foi acoplado (somente leitura) */
105
- declare readonly $el: HTMLElement
106
-
107
- /** Map nativo indexando elementos referenciados via ui:ref / :ref (somente leitura) */
108
- protected readonly $refs: Map<string, any> = new Map<string, any>()
109
-
110
- /** Despacha CustomEvents nativos (bubbles: true, composed: true) */
111
- protected $emit(event: string, detail?: any): void {
112
- const target = this.$el || (typeof window !== 'undefined' ? window : null)
113
- target?.dispatchEvent(
114
- new CustomEvent(event, { detail, bubbles: true, composed: true }),
115
- )
116
- }
117
-
118
- declare $index?: number
119
- declare $key?: any
120
-
121
- constructor(init?: Record<string, any>) {
122
- if (init && typeof init === 'object') {
123
- Object.assign(this, init)
124
- }
125
- }
126
- }
127
-
128
- export function reactive<T extends any[]>(target: T): T
129
- export function reactive<T extends object>(
130
- target: T & ThisType<T & ScopeContext>,
131
- ): T & ScopeContext
132
- export function reactive<T extends object>(target: T): any {
133
- const notObject = typeof target !== 'object' || target === null
134
- if (notObject) return target
135
-
136
- if (
137
- target instanceof Map ||
138
- target instanceof Set ||
139
- target instanceof WeakMap ||
140
- target instanceof WeakSet ||
141
- target instanceof Date ||
142
- target instanceof RegExp ||
143
- (typeof Node === 'function' && target instanceof Node)
144
- ) {
145
- return target
146
- }
147
-
148
- const isReactive = Object.hasOwn(target, '_isReactive')
149
- if (isReactive) return target
150
-
151
- const existingProxy = proxyMap.get(target)
152
- if (existingProxy) return existingProxy
153
-
154
- const proxy = new Proxy(target, {
155
- get(obj, key, receiver) {
156
- if (key === '_isReactive') return true
157
- track(obj, key)
158
-
159
- const res = Reflect.get(obj, key, receiver)
160
- // deep reactivity
161
- return typeof res === 'object' && res !== null ? reactive(res) : res
162
- },
163
- set(obj, key, value, receiver) {
164
- const isArray = Array.isArray(obj)
165
- const oldValue = Reflect.get(obj, key, receiver)
166
- const hadKey =
167
- isArray && String(Number(key)) === key
168
- ? Number(key) < obj.length
169
- : Object.hasOwn(obj, key)
170
-
171
- const result = Reflect.set(obj, key, value, receiver)
172
-
173
- if (!hadKey) {
174
- trigger(obj, key)
175
- if (isArray && key !== 'length') {
176
- trigger(obj, 'length')
177
- }
178
- } else if (oldValue !== value) {
179
- trigger(obj, key)
180
- }
181
-
182
- return result
183
- },
184
- })
185
-
186
- proxyMap.set(target, proxy)
187
- return proxy
188
- }
189
-
190
- export function traverse(value: any, seen = new Set()) {
191
- if (typeof value !== 'object' || value === null || seen.has(value)) {
192
- return value
193
- }
194
- seen.add(value)
195
- for (const key in value) {
196
- traverse(value[key], seen)
197
- }
198
- return value
199
- }
200
-
201
- export function watch<T>(
202
- source: (() => T) | any,
203
- cb: (newValue: T, oldValue: T | undefined) => void,
204
- options?: { immediate?: boolean },
205
- ): () => void {
206
- let oldValue: any
207
- let isFirstRun = true
208
-
209
- const getter = source instanceof Function ? source : () => traverse(source)
210
-
211
- const runner = effect(() => {
212
- const newValue = getter()
213
-
214
- if (isFirstRun) {
215
- isFirstRun = false
216
- oldValue = newValue
217
- if (options?.immediate) {
218
- cb(newValue, undefined)
219
- }
220
- } else {
221
- cb(newValue, oldValue)
222
- oldValue = newValue
223
- }
224
- })
225
-
226
- return runner.stop
227
- }