@jsweb/ui 1.3.0 → 1.3.2
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/CHANGELOG.md +155 -0
- package/README.md +72 -7
- package/SKILL.md +609 -0
- package/index.es.js +2 -0
- package/index.es.js.map +1 -0
- package/index.umd.js +2 -0
- package/index.umd.js.map +1 -0
- package/package.json +10 -23
- package/src/index.d.ts +4 -0
- package/{dist/src → src}/parser.d.ts +2 -1
- package/src/reactivity.d.ts +48 -0
- package/.github/workflows/npm-publish.yml +0 -19
- package/.prettierignore +0 -3
- package/.prettierrc +0 -7
- package/PROJECT.md +0 -106
- package/dist/LICENSE +0 -21
- package/dist/README.md +0 -227
- package/dist/index.es.js +0 -2
- package/dist/index.es.js.map +0 -1
- package/dist/index.umd.js +0 -2
- package/dist/index.umd.js.map +0 -1
- package/dist/package.json +0 -34
- package/dist/src/index.d.ts +0 -3
- package/dist/src/reactivity.d.ts +0 -24
- package/dist/vite.config.d.ts +0 -2
- package/index.html +0 -196
- package/publish.js +0 -34
- package/src/evaluator.ts +0 -29
- package/src/index.ts +0 -10
- package/src/parser.ts +0 -480
- package/src/reactivity.ts +0 -185
- package/tsconfig.json +0 -23
- package/vite.config.ts +0 -16
- /package/{dist/index.d.ts → index.d.ts} +0 -0
- /package/{dist/src → src}/evaluator.d.ts +0 -0
package/SKILL.md
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: jsweb-ui
|
|
3
|
+
description: Comprehensive guide and operational instructions for building reactive user interfaces with the @jsweb/ui micro-framework. Use this skill whenever creating, modifying, or refactoring web applications that use @jsweb/ui, or when building fine-grained reactive HTML components with custom directives (ui:*, :*, ui@*, @*), two-way data binding, conditional rendering, keyed list loops, DOM refs, and shared reactive stores without a Virtual DOM.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @jsweb/ui Skill Guide
|
|
7
|
+
|
|
8
|
+
`@jsweb/ui` is a lightweight, zero-dependency frontend micro-framework written in TypeScript. It delivers Vue 3-inspired fine-grained reactivity (`Proxy` + `ReactiveEffect`) combined with Alpine.js-style declarative HTML attributes, applying updates directly to the real DOM without any Virtual DOM overhead.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. Quick Start & Installation
|
|
13
|
+
|
|
14
|
+
### NPM / Bundler (Vite, Webpack, Rollup)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @jsweb/ui
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { createScope, reactive, watch } from '@jsweb/ui'
|
|
22
|
+
|
|
23
|
+
const state = reactive({
|
|
24
|
+
count: 0,
|
|
25
|
+
increment() {
|
|
26
|
+
this.count++
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
createScope('#app', state)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Standalone CDN (Direct HTML / No Build Step)
|
|
34
|
+
|
|
35
|
+
Available via unpkg or jsDelivr. Exposes the global object `window.jsweb.ui`:
|
|
36
|
+
|
|
37
|
+
```html
|
|
38
|
+
<!doctype html>
|
|
39
|
+
<html lang="en">
|
|
40
|
+
<head>
|
|
41
|
+
<meta charset="UTF-8" />
|
|
42
|
+
<script src="https://unpkg.com/@jsweb/ui"></script>
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
<div :scope="{ count: 0 }">
|
|
46
|
+
<h1 :text="count"></h1>
|
|
47
|
+
<button @click="count++">+</button>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<script>
|
|
51
|
+
const { createScope } = window.jsweb.ui
|
|
52
|
+
createScope('body')
|
|
53
|
+
</script>
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 2. Core Architecture & Mental Model
|
|
61
|
+
|
|
62
|
+
1. **No Virtual DOM**: Changes to reactive properties mutate the real DOM directly using targeted granular effects.
|
|
63
|
+
2. **Deep Proxy Reactivity**: State created with `reactive()` wraps objects and arrays into reactive Proxies. Mutating nested properties or calling array methods (`push`, `pop`, `splice`, `shift`, etc.) triggers targeted DOM re-renders.
|
|
64
|
+
3. **Template Directives Cleanup**: Directive attributes (`:text`, `:bind`, `:if`, `:for`, `:class`, etc.) are evaluated and immediately removed from DOM elements after mounting, keeping the HTML clean.
|
|
65
|
+
4. **Context Hierarchy**: Scopes inherit variables from outer scopes or parent contexts. Expressions are safely evaluated in the context scope with dynamic evaluation (`with(this)`).
|
|
66
|
+
5. **Memory Safe & Auto-Cleanup**: When elements are removed (via `:if` or `:for`), all associated event listeners and reactive effects are automatically cleaned up.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 3. JavaScript / TypeScript API Reference
|
|
71
|
+
|
|
72
|
+
### `reactive(target)`
|
|
73
|
+
|
|
74
|
+
Creates a deep reactive Proxy of the given object or array.
|
|
75
|
+
|
|
76
|
+
- **Object Literal Typing (`ThisType`)**: Wrapped in `ThisType<T & ScopeContext>`, enabling complete IDE autocompletion and strict type checking for `this.property`, `this.method()`, computeds via getters, and framework helpers (`this.$refs`, `this.$emit`).
|
|
77
|
+
- **Class Instances**: Fully supports TypeScript class instances (including those extending `Scope`).
|
|
78
|
+
- **Deep tracking**: Accessing nested objects returns nested reactive proxies automatically.
|
|
79
|
+
- **Array mutations**: Tracks index sets and automatically triggers updates for array length changes.
|
|
80
|
+
- **Computed properties**: Use native JavaScript getters (`get prop() { return ... }`). When accessed inside templates or effects, dependencies are automatically tracked.
|
|
81
|
+
- **Bypassed types**: `Map`, `Set`, `WeakMap`, `WeakSet`, `Date`, `RegExp`, and DOM `Node` instances are preserved as-is without proxy wrapping.
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import { reactive } from '@jsweb/ui'
|
|
85
|
+
|
|
86
|
+
const store = reactive({
|
|
87
|
+
items: ['Learn @jsweb/ui', 'Build an app'],
|
|
88
|
+
filter: '',
|
|
89
|
+
// Native getter acts as a computed property:
|
|
90
|
+
get filteredItems() {
|
|
91
|
+
return this.items.filter((item) =>
|
|
92
|
+
item.toLowerCase().includes(this.filter.toLowerCase()),
|
|
93
|
+
)
|
|
94
|
+
},
|
|
95
|
+
addItem(text: string) {
|
|
96
|
+
if (text.trim()) {
|
|
97
|
+
this.items.push(text.trim())
|
|
98
|
+
this.$emit('item-added', text.trim())
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
### `Scope` Class & `ScopeContext` Interface
|
|
107
|
+
|
|
108
|
+
For Object-Oriented component architecture, extend the base `Scope` class. `$refs` is implemented as a read-only `Map` and `$emit` has a standard implementation as `protected`, allowing subclasses to override it:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
export interface ScopeContext {
|
|
112
|
+
/** Read-only Map indexing referenced DOM nodes */
|
|
113
|
+
readonly $refs: Map<string, any>
|
|
114
|
+
/** Dispatches CustomEvents */
|
|
115
|
+
$emit: (event: string, detail?: any) => void
|
|
116
|
+
/** Root DOM element attached to the scope (read-only) */
|
|
117
|
+
readonly $el: HTMLElement
|
|
118
|
+
/** Current loop index */
|
|
119
|
+
$index?: number
|
|
120
|
+
/** Unique key for loop iteration */
|
|
121
|
+
$key?: any
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export class Scope {
|
|
125
|
+
readonly $el: HTMLElement
|
|
126
|
+
protected readonly $refs: Map<string, any>
|
|
127
|
+
protected $emit(event: string, detail?: any): void
|
|
128
|
+
declare $index?: number
|
|
129
|
+
declare $key?: any
|
|
130
|
+
constructor(init?: Record<string, any>)
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
#### OOP Example:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
import { createScope, reactive, Scope } from '@jsweb/ui'
|
|
138
|
+
|
|
139
|
+
class TodoListScope extends Scope {
|
|
140
|
+
tasks: string[] = []
|
|
141
|
+
newTask = ''
|
|
142
|
+
|
|
143
|
+
get count() {
|
|
144
|
+
return this.tasks.length
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
addTask() {
|
|
148
|
+
if (this.newTask.trim()) {
|
|
149
|
+
this.tasks.push(this.newTask.trim())
|
|
150
|
+
this.newTask = ''
|
|
151
|
+
this.$emit('tasks-updated', this.tasks)
|
|
152
|
+
this.$refs.get('taskInput')?.focus()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Can override $emit if custom logic is needed:
|
|
157
|
+
protected override $emit(event: string, detail?: any) {
|
|
158
|
+
console.log(`[Event: ${event}]`, detail)
|
|
159
|
+
super.$emit(event, detail)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const todoScope = reactive(new TodoListScope())
|
|
164
|
+
createScope('#todo-app', todoScope)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
### `watch<T>(source, callback, options?)`
|
|
170
|
+
|
|
171
|
+
Watches a reactive property or getter function and fires a callback when its value changes.
|
|
172
|
+
|
|
173
|
+
- `source`: A getter function `() => value` or a reactive object/array directly (auto-traversed).
|
|
174
|
+
- `callback`: `(newValue: T, oldValue: T | undefined) => void`.
|
|
175
|
+
- `options`: `{ immediate?: boolean }`. If `immediate: true`, the callback runs immediately on registration (`oldValue` will be `undefined`).
|
|
176
|
+
- **Returns**: A `stop()` function to cancel the watcher.
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
import { reactive, watch } from '@jsweb/ui'
|
|
180
|
+
|
|
181
|
+
const state = reactive({ count: 0 })
|
|
182
|
+
|
|
183
|
+
const unwatch = watch(
|
|
184
|
+
() => state.count,
|
|
185
|
+
(newVal, oldVal) => {
|
|
186
|
+
console.log(`Count changed from ${oldVal} to ${newVal}`)
|
|
187
|
+
},
|
|
188
|
+
{ immediate: true },
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
// When done:
|
|
192
|
+
// unwatch()
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
### `createScope<T>(selectorOrElement, context?)`
|
|
198
|
+
|
|
199
|
+
Mounts the reactive engine on a DOM element and parses directives within that tree.
|
|
200
|
+
|
|
201
|
+
- `selectorOrElement`: A CSS selector string (e.g. `'#app'`, `'body'`) or an `HTMLElement`.
|
|
202
|
+
- `context`: An optional initial state object or shared context, typed contextually with `ThisType<T & ScopeContext>`.
|
|
203
|
+
- **Automatic Injections**:
|
|
204
|
+
- `context.$emit`: Dispatches bubbling, composed `CustomEvent`s.
|
|
205
|
+
- `context.$refs`: Native `Map<string, any>` storing DOM element references.
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
import { createScope, reactive } from '@jsweb/ui'
|
|
209
|
+
|
|
210
|
+
const appState = reactive({
|
|
211
|
+
title: 'My Application',
|
|
212
|
+
user: { name: 'Alice' },
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
createScope('#app', appState)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## 4. Template Directives & Attributes
|
|
221
|
+
|
|
222
|
+
All directives support both the full syntax (`ui:*`, `ui@*`) and the concise shorthand (`:*`, `@*`). The shorthand is recommended for concise templates.
|
|
223
|
+
|
|
224
|
+
| Directive | Shorthand | Purpose | Example |
|
|
225
|
+
| :----------- | :--------- | :----------------------------------------------------- | :----------------------------------------- |
|
|
226
|
+
| `ui:scope` | `:scope` | Defines local state scope for element and descendants | `<div :scope="{ open: false }">` |
|
|
227
|
+
| `ui:text` | `:text` | Sets reactive `textContent` | `<span :text="username"></span>` |
|
|
228
|
+
| `ui:bind` | `:bind` | Two-way data binding for form inputs | `<input type="text" :bind="query" />` |
|
|
229
|
+
| `ui:if` | `:if` | Conditionally mounts/unmounts element in DOM | `<div :if="isLoggedIn">Welcome</div>` |
|
|
230
|
+
| `ui:for` | `:for` | Repeats element over an array (`in` or `of`) | `<li :for="item of items">` |
|
|
231
|
+
| `ui:key` | `:key` | Reconciliation key for list recycling | `<li :for="user of users" :key="user.id">` |
|
|
232
|
+
| `ui:class` | `:class` | Reactive CSS classes (Object, Array, or String) | `<div :class="{ active: isActive }">` |
|
|
233
|
+
| `ui:style` | `:style` | Reactive inline styles (Object key-value) | `<div :style="{ color: themeColor }">` |
|
|
234
|
+
| `ui:ref` | `:ref` | Registers DOM element into `$refs` `Map` | `<input :ref="searchField" />` |
|
|
235
|
+
| `ui:[attr]` | `:[attr]` | Dynamic attribute binding (`:disabled`, `:href`, etc.) | `<button :disabled="isSubmitting">` |
|
|
236
|
+
| `ui@[event]` | `@[event]` | Event listener with modifier support | `<button @click.prevent="submit">` |
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
### Detailed Directive Specifications
|
|
241
|
+
|
|
242
|
+
#### 1. `:scope`
|
|
243
|
+
|
|
244
|
+
Sets up a local reactive scope. If an object is supplied, it is merged into the context hierarchy.
|
|
245
|
+
|
|
246
|
+
```html
|
|
247
|
+
<div :scope="{ count: 0, title: 'Counter' }">
|
|
248
|
+
<h2 :text="title"></h2>
|
|
249
|
+
<button @click="count++">Increment</button>
|
|
250
|
+
<span :text="count"></span>
|
|
251
|
+
</div>
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
#### 2. `:text`
|
|
255
|
+
|
|
256
|
+
Updates `textContent`. If the evaluated value is `null` or `undefined`, sets empty text `""`.
|
|
257
|
+
|
|
258
|
+
```html
|
|
259
|
+
<p>Total: <strong :text="totalPrice"></strong></p>
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
#### 3. `:bind` (Two-Way Data Binding)
|
|
263
|
+
|
|
264
|
+
Automatically handles different input types:
|
|
265
|
+
|
|
266
|
+
- **Text / Number / Search / Textarea**: Binds `value` property, updates state on the `'input'` event.
|
|
267
|
+
- **Checkbox (`type="checkbox"`)**: Binds boolean `checked` property, updates state on `'change'`.
|
|
268
|
+
- **Radio (`type="radio"`)**: Sets `checked = (el.value === String(stateValue))`, updates state on `'change'`.
|
|
269
|
+
- **Select (`<select>`)**: Binds `value` property, updates state on `'change'`.
|
|
270
|
+
|
|
271
|
+
```html
|
|
272
|
+
<!-- Text / Textarea -->
|
|
273
|
+
<input type="text" :bind="user.name" />
|
|
274
|
+
<textarea :bind="user.bio"></textarea>
|
|
275
|
+
|
|
276
|
+
<!-- Checkbox (boolean) -->
|
|
277
|
+
<input type="checkbox" :bind="user.agreeToTerms" />
|
|
278
|
+
|
|
279
|
+
<!-- Radios -->
|
|
280
|
+
<input type="radio" name="plan" value="free" :bind="selectedPlan" /> Free
|
|
281
|
+
<input type="radio" name="plan" value="pro" :bind="selectedPlan" /> Pro
|
|
282
|
+
|
|
283
|
+
<!-- Select -->
|
|
284
|
+
<select :bind="selectedCountry">
|
|
285
|
+
<option value="BR">Brazil</option>
|
|
286
|
+
<option value="US">USA</option>
|
|
287
|
+
</select>
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
#### 4. `:if`
|
|
291
|
+
|
|
292
|
+
Performs true conditional rendering. When false, the element is detached from the DOM and replaced by a lightweight comment node placeholder. When true, it is inserted back.
|
|
293
|
+
|
|
294
|
+
```html
|
|
295
|
+
<div :if="errorMessage" class="alert alert-danger">
|
|
296
|
+
<span :text="errorMessage"></span>
|
|
297
|
+
</div>
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
#### 5. `:for` and `:key`
|
|
301
|
+
|
|
302
|
+
Iterates over an array using `item of items` or `item in items`.
|
|
303
|
+
|
|
304
|
+
- **Always provide `:key`** for optimal performance and reliable reconciliation.
|
|
305
|
+
- Exposes `$index` (0-based numeric index) and `$key` in the loop's context.
|
|
306
|
+
- Recycles existing DOM nodes based on key matches, preserving focus, scroll, and component state.
|
|
307
|
+
|
|
308
|
+
```html
|
|
309
|
+
<ul>
|
|
310
|
+
<li :for="task of tasks" :key="task.id">
|
|
311
|
+
<span :text="$index + 1"></span>.
|
|
312
|
+
<span :text="task.title"></span>
|
|
313
|
+
<button @click="removeTask(task.id)">Delete</button>
|
|
314
|
+
</li>
|
|
315
|
+
</ul>
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
#### 6. `:class`
|
|
319
|
+
|
|
320
|
+
Dynamically manages CSS classes without removing existing static classes on the element.
|
|
321
|
+
|
|
322
|
+
- **Object Syntax**: Keys are class names, values are boolean conditions.
|
|
323
|
+
```html
|
|
324
|
+
<div
|
|
325
|
+
class="card"
|
|
326
|
+
:class="{ 'card-active': isActive, 'border-danger': hasError }"
|
|
327
|
+
></div>
|
|
328
|
+
```
|
|
329
|
+
- **Array Syntax**: Array of class name strings or ternary expressions.
|
|
330
|
+
```html
|
|
331
|
+
<div
|
|
332
|
+
:class="['badge', isPrimary ? 'badge-primary' : 'badge-secondary']"
|
|
333
|
+
></div>
|
|
334
|
+
```
|
|
335
|
+
- **String Syntax**: Single dynamic class name string.
|
|
336
|
+
```html
|
|
337
|
+
<div :class="themeClass"></div>
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
#### 7. `:style`
|
|
341
|
+
|
|
342
|
+
Receives an object of CSS properties. Automatically removes previous styles when keys are removed or cleared.
|
|
343
|
+
|
|
344
|
+
```html
|
|
345
|
+
<div
|
|
346
|
+
:style="{ backgroundColor: bgColor, transform: `scale(${scale})`, opacity: isVisible ? 1 : 0 }"
|
|
347
|
+
></div>
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
#### 8. `:ref` and `$refs`
|
|
351
|
+
|
|
352
|
+
Registers the DOM element into the context's `$refs` `Map`.
|
|
353
|
+
|
|
354
|
+
- **Single Element**: Directly stored as the `HTMLElement`.
|
|
355
|
+
```html
|
|
356
|
+
<input type="text" :ref="nameInput" />
|
|
357
|
+
<button @click="$refs.get('nameInput').focus()">Focus Input</button>
|
|
358
|
+
```
|
|
359
|
+
- **Inside Keyed `:for` Loops**: Stored as a nested `Map<Key, HTMLElement>`.
|
|
360
|
+
```html
|
|
361
|
+
<ul>
|
|
362
|
+
<li :for="item of items" :key="item.id">
|
|
363
|
+
<input :ref="itemField" :value="item.name" />
|
|
364
|
+
<button @click="$refs.get('itemField').get(item.id)?.focus()">
|
|
365
|
+
Focus this row
|
|
366
|
+
</button>
|
|
367
|
+
</li>
|
|
368
|
+
</ul>
|
|
369
|
+
```
|
|
370
|
+
- **Automatic Lifecycle Cleanup**: When an element leaves the DOM (via `:if` or `:for`), its entry is removed from the `$refs` map to prevent memory leaks.
|
|
371
|
+
|
|
372
|
+
#### 9. Dynamic Attributes (`:[attr]`)
|
|
373
|
+
|
|
374
|
+
Binds any HTML attribute dynamically:
|
|
375
|
+
|
|
376
|
+
- If evaluated value is `false`, `null`, or `undefined`: attribute is removed.
|
|
377
|
+
- If evaluated value is `true`: attribute is added with empty value (`el.setAttribute(attr, '')`).
|
|
378
|
+
- Otherwise: `el.setAttribute(attr, String(value))`.
|
|
379
|
+
|
|
380
|
+
```html
|
|
381
|
+
<button :disabled="isLoading">Submit</button>
|
|
382
|
+
<a :href="user.profileUrl" :title="user.bio">Profile</a>
|
|
383
|
+
<img :src="imageUrl" :alt="imageAlt" />
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
## 5. Event Handling & Modifiers
|
|
389
|
+
|
|
390
|
+
### Event Directives (`@[event]` / `ui@[event]`)
|
|
391
|
+
|
|
392
|
+
Attach DOM event listeners.
|
|
393
|
+
|
|
394
|
+
```html
|
|
395
|
+
<!-- Method reference: automatically receives $event as first argument -->
|
|
396
|
+
<button @click="handleClick">Click Me</button>
|
|
397
|
+
|
|
398
|
+
<!-- Inline expression: $event is explicitly available -->
|
|
399
|
+
<button @click="handleClick($event, 'customArg')">Click Me</button>
|
|
400
|
+
|
|
401
|
+
<!-- Direct mutation -->
|
|
402
|
+
<button @click="count++">Increment</button>
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### Event Modifiers
|
|
406
|
+
|
|
407
|
+
Modifiers can be chained directly to the event name:
|
|
408
|
+
|
|
409
|
+
- **`.prevent`**: Calls `$event.preventDefault()`.
|
|
410
|
+
- **`.stop`**: Calls `$event.stopPropagation()`.
|
|
411
|
+
- **`.self`**: Only executes the handler if `$event.target === el` (the event originated on the bound element itself).
|
|
412
|
+
- **`.outside`**: Dispatches when a click or event occurs outside the element (ideal for dropdowns, tooltips, and modals). Sets up a document listener with automatic cleanup when the element unmounts.
|
|
413
|
+
|
|
414
|
+
```html
|
|
415
|
+
<!-- Prevent default form submission -->
|
|
416
|
+
<form @submit.prevent="saveData">
|
|
417
|
+
<input :bind="formData.title" />
|
|
418
|
+
<button type="submit">Save</button>
|
|
419
|
+
</form>
|
|
420
|
+
|
|
421
|
+
<!-- Stop event propagation -->
|
|
422
|
+
<div @click="outerClick">
|
|
423
|
+
<button @click.stop="innerClick">Inner</button>
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
<!-- Click outside to close modal or dropdown -->
|
|
427
|
+
<div class="dropdown-menu" :if="isOpen" @click.outside="isOpen = false">
|
|
428
|
+
<p>Dropdown Content</p>
|
|
429
|
+
</div>
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## 6. Context Helpers & Variables
|
|
435
|
+
|
|
436
|
+
Available within template expressions and component methods:
|
|
437
|
+
|
|
438
|
+
| Variable | Type | Description |
|
|
439
|
+
| :---------------------- | :----------------- | :-------------------------------------------------------------------------- |
|
|
440
|
+
| `$refs` | `Map<string, any>` | Map of registered element refs. |
|
|
441
|
+
| `$emit(event, detail?)` | `Function` | Dispatches a bubbling composed `CustomEvent` from the scope's root element. |
|
|
442
|
+
| `$el` | `HTMLElement` | The root DOM element attached to the scope (read-only). |
|
|
443
|
+
| `$event` | `Event` | The native DOM event object (available in event expressions). |
|
|
444
|
+
| `$index` | `number` | The current 0-based iteration index in a `:for` loop. |
|
|
445
|
+
| `$key` | `any` | The current key value evaluated for the item in a `:for` loop. |
|
|
446
|
+
|
|
447
|
+
### Component Communication with `$emit`
|
|
448
|
+
|
|
449
|
+
Child scopes can dispatch events up the DOM tree, and parents can listen with standard `@` event syntax:
|
|
450
|
+
|
|
451
|
+
```html
|
|
452
|
+
<div id="parent-component" @task-added="handleNewTask">
|
|
453
|
+
<!-- Child Component / Scope -->
|
|
454
|
+
<div :scope="{ newTaskName: '' }">
|
|
455
|
+
<input :bind="newTaskName" placeholder="New task..." />
|
|
456
|
+
<button
|
|
457
|
+
@click="$emit('task-added', { name: newTaskName }); newTaskName = ''"
|
|
458
|
+
>
|
|
459
|
+
Add Task
|
|
460
|
+
</button>
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
467
|
+
## 7. Common Patterns & Best Practices
|
|
468
|
+
|
|
469
|
+
### Pattern 1: Standalone Interactive Widget (HTML Only)
|
|
470
|
+
|
|
471
|
+
No build step needed. Place everything in an HTML file:
|
|
472
|
+
|
|
473
|
+
```html
|
|
474
|
+
<div
|
|
475
|
+
id="calculator"
|
|
476
|
+
:scope="{
|
|
477
|
+
a: 0,
|
|
478
|
+
b: 0,
|
|
479
|
+
get sum() { return Number(this.a) + Number(this.b) }
|
|
480
|
+
}"
|
|
481
|
+
>
|
|
482
|
+
<input type="number" :bind="a" /> + <input type="number" :bind="b" /> =
|
|
483
|
+
<span :text="sum"></span>
|
|
484
|
+
</div>
|
|
485
|
+
|
|
486
|
+
<script>
|
|
487
|
+
window.jsweb.ui.createScope('#calculator')
|
|
488
|
+
</script>
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
### Pattern 2: Global Shared Store (TypeScript / ESM)
|
|
492
|
+
|
|
493
|
+
Export a `reactive` store from a module and import it across multiple views or components:
|
|
494
|
+
|
|
495
|
+
```typescript
|
|
496
|
+
// store.ts
|
|
497
|
+
import { reactive } from '@jsweb/ui'
|
|
498
|
+
|
|
499
|
+
export const authStore = reactive({
|
|
500
|
+
user: null as { name: string; token: string } | null,
|
|
501
|
+
get isAuthenticated() {
|
|
502
|
+
return this.user !== null
|
|
503
|
+
},
|
|
504
|
+
login(name: string, token: string) {
|
|
505
|
+
this.user = { name, token }
|
|
506
|
+
},
|
|
507
|
+
logout() {
|
|
508
|
+
this.user = null
|
|
509
|
+
},
|
|
510
|
+
})
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
```typescript
|
|
514
|
+
// main.ts
|
|
515
|
+
import { createScope } from '@jsweb/ui'
|
|
516
|
+
import { authStore } from './store'
|
|
517
|
+
|
|
518
|
+
createScope('#navbar', { auth: authStore })
|
|
519
|
+
createScope('#main-content', { auth: authStore })
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
### Pattern 3: Full CRUD List with Keys & Refs
|
|
523
|
+
|
|
524
|
+
```html
|
|
525
|
+
<div id="todo-app">
|
|
526
|
+
<h2>Todo List</h2>
|
|
527
|
+
|
|
528
|
+
<form @submit.prevent="addTodo">
|
|
529
|
+
<input
|
|
530
|
+
type="text"
|
|
531
|
+
:bind="newTitle"
|
|
532
|
+
:ref="titleInput"
|
|
533
|
+
placeholder="Add task..."
|
|
534
|
+
/>
|
|
535
|
+
<button type="submit" :disabled="!newTitle.trim()">Add</button>
|
|
536
|
+
</form>
|
|
537
|
+
|
|
538
|
+
<ul>
|
|
539
|
+
<li :for="todo of todos" :key="todo.id">
|
|
540
|
+
<input type="checkbox" :bind="todo.completed" />
|
|
541
|
+
<span
|
|
542
|
+
:style="{ textDecoration: todo.completed ? 'line-through' : 'none' }"
|
|
543
|
+
:text="todo.title"
|
|
544
|
+
></span>
|
|
545
|
+
<button @click="deleteTodo(todo.id)">✕</button>
|
|
546
|
+
</li>
|
|
547
|
+
</ul>
|
|
548
|
+
</div>
|
|
549
|
+
|
|
550
|
+
<script type="module">
|
|
551
|
+
import { createScope, reactive } from '@jsweb/ui'
|
|
552
|
+
|
|
553
|
+
let nextId = 1
|
|
554
|
+
const app = reactive({
|
|
555
|
+
newTitle: '',
|
|
556
|
+
todos: [
|
|
557
|
+
{ id: nextId++, title: 'Buy milk', completed: false },
|
|
558
|
+
{ id: nextId++, title: 'Write tests', completed: true },
|
|
559
|
+
],
|
|
560
|
+
addTodo() {
|
|
561
|
+
if (!this.newTitle.trim()) return
|
|
562
|
+
this.todos.push({
|
|
563
|
+
id: nextId++,
|
|
564
|
+
title: this.newTitle.trim(),
|
|
565
|
+
completed: false,
|
|
566
|
+
})
|
|
567
|
+
this.newTitle = ''
|
|
568
|
+
this.$refs.get('titleInput')?.focus()
|
|
569
|
+
},
|
|
570
|
+
deleteTodo(id) {
|
|
571
|
+
const idx = this.todos.findIndex((t) => t.id === id)
|
|
572
|
+
if (idx !== -1) this.todos.splice(idx, 1)
|
|
573
|
+
},
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
createScope('#todo-app', app)
|
|
577
|
+
</script>
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### Pattern 4: Modal / Popover with `@click.outside`
|
|
581
|
+
|
|
582
|
+
```html
|
|
583
|
+
<div :scope="{ isOpen: false }">
|
|
584
|
+
<button @click="isOpen = true">Open Details</button>
|
|
585
|
+
|
|
586
|
+
<div class="modal-backdrop" :if="isOpen">
|
|
587
|
+
<div class="modal-card" @click.outside="isOpen = false">
|
|
588
|
+
<h3>Modal Title</h3>
|
|
589
|
+
<p>Clicking outside this box automatically closes the modal.</p>
|
|
590
|
+
<button @click="isOpen = false">Close</button>
|
|
591
|
+
</div>
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
598
|
+
## 8. Critical Rules for AI Coding Agents
|
|
599
|
+
|
|
600
|
+
When generating, editing, or diagnosing `@jsweb/ui` code, adhere to these rules:
|
|
601
|
+
|
|
602
|
+
1. **Do NOT inject Virtual DOM or JSX**: `@jsweb/ui` operates directly on standard HTML DOM nodes. Do not import React, JSX runtimes, or attempt to return VNodes.
|
|
603
|
+
2. **Always supply `:key` in `:for` loops**: When generating lists with `:for`, always include `:key="item.id"` (or another unique identifier) to ensure efficient element recycling and avoid state leakage between list items.
|
|
604
|
+
3. **Use Native Getters for Computed State**: Do not invent a `computed()` function. `@jsweb/ui` uses standard JavaScript getters on reactive objects (`get myProp() { ... }`).
|
|
605
|
+
4. **Wrap Root State with `reactive()`**: If mutating properties from JavaScript, ensure the object was wrapped in `reactive()`. Mutating a plain unproxied object will not trigger DOM updates.
|
|
606
|
+
5. **Remember Directive Attributes Disappear from DOM**: Directives like `:text`, `:bind`, and `:if` are stripped during parsing. Do not write CSS selectors or external DOM queries relying on directive attributes remaining on elements in runtime.
|
|
607
|
+
6. **Use `$refs.get('key')`**: `$refs` is a JavaScript `Map`. Access references using `$refs.get('name')`, not `$refs.name`. In keyed `:for` loops, access nested elements with `$refs.get('name').get(key)`.
|
|
608
|
+
7. **Two-Way Binding on Inputs**: Always use `:bind="prop"`. Avoid manually pairing `:value="prop"` and `@input="prop = $event.target.value"` unless custom debouncing or data conversion is needed.
|
|
609
|
+
8. **Scope Merging**: When calling `createScope(selector, state)`, the properties of `state` become directly accessible in that DOM subtree. If `:scope="{ ... }"` is also defined on the HTML element, the inline scope inherits and shadows properties from the parent state.
|
package/index.es.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var e=null,t=/* @__PURE__ */new WeakMap,n=/* @__PURE__ */new WeakMap,o=/* @__PURE__ */new WeakMap,s=class{fn;active=!0;deps=/* @__PURE__ */new Set;constructor(e){this.fn=e}run(){if(!this.active)return this.fn();this.cleanup(),e=Symbol(),o.set(e,this);try{return this.fn()}finally{o.delete(e),e=null}}stop(){this.active&&(this.cleanup(),this.active=!1)}cleanup(){this.deps.forEach(e=>e.delete(this)),this.deps.clear()}effect(){return{run:()=>this.run(),stop:()=>this.stop()}}};function i(e){const t=new s(e);return t.run(),t.effect()}function c(e,n){const o=t.get(e);if(!o)return;const s=o.get(n);s&&new Set(s).forEach(e=>e.run())}var r=class{$refs=/* @__PURE__ */new Map;$emit(e,t){(this.$el||("undefined"!=typeof window?window:null))?.dispatchEvent(new CustomEvent(e,{detail:t,bubbles:!0,composed:!0}))}constructor(e){e&&"object"==typeof e&&Object.assign(this,e)}};function f(s){if("object"!=typeof s||null===s)return s;if(s instanceof Map||s instanceof Set||s instanceof WeakMap||s instanceof WeakSet||s instanceof Date||s instanceof RegExp||"function"==typeof Node&&s instanceof Node)return s;if(Object.hasOwn(s,"_isReactive"))return s;const i=n.get(s);if(i)return i;const r=new Proxy(s,{get(n,s,i){if("_isReactive"===s)return!0;!function(n,s){if(e){let i=t.get(n);i||(i=/* @__PURE__ */new Map,t.set(n,i));let c=i.get(s);c||(c=/* @__PURE__ */new Set,i.set(s,c));const r=o.get(e);r&&(c.add(r),r.deps.add(c))}}(n,s);const c=Reflect.get(n,s,i);return"object"==typeof c&&null!==c?f(c):c},set(e,t,n,o){const s=Array.isArray(e),i=Reflect.get(e,t,o),r=s&&String(Number(t))===t?Number(t)<e.length:Object.hasOwn(e,t),f=Reflect.set(e,t,n,o);return r?i!==n&&c(e,t):(c(e,t),s&&"length"!==t&&c(e,"length")),f}});return n.set(s,r),r}function u(e,t=/* @__PURE__ */new Set){if("object"!=typeof e||null===e||t.has(e))return e;t.add(e);for(const n in e)u(e[n],t);return e}function a(e,t,n){let o,s=!0;const c=e instanceof Function?e:()=>u(e);return i(()=>{const e=c();s?(s=!1,o=e,n?.immediate&&t(e,void 0)):(t(e,o),o=e)}).stop}function l(e,t={}){try{return new Function(`with(this) { return ${e} }`).call(t)}catch{return}}function d(e,t,n={}){try{const o=t.trim(),s=/^[a-zA-Z_$][0-9a-zA-Z_$.]*$/.test(o);new Function("$event",`with(this) { ${s?`${o} instanceof Function ? ${o}.call(this, $event) : ${o}`:o} }`).call(n,e)}catch{console.warn(`[jsweb/ui] Error evaluating event: ${t}`)}}function p(e){const t=e;t._effects&&(t._effects.forEach(e=>e()),t._effects=[]);const n=Array.from(e.childNodes);for(const o of n)p(o)}function h(e,t={}){const n=e._isReactive?e:f(e);return new Proxy(n,{get:(e,n)=>"_isContext"===n||(n in e?Reflect.get(e,n,e):n in t?Reflect.get(t,n,t):Reflect.get(e,n,e)),set:(e,n,o)=>n in e?Reflect.set(e,n,o,e):n in t?Reflect.set(t,n,o,t):Reflect.set(e,n,o,e),has:(e,n)=>n in e||n in t})}function m(e,t){if(e.nodeType!==Node.ELEMENT_NODE)return;const n=e,o=function(e,t){const n=["ui:scope",":scope"],o=w(e,n);if(!o)return t;const s=l(o,t);if(!s)return;y(e,n),s.$el=e,s.$emit||(s.$emit=(t,n)=>{e.dispatchEvent(new CustomEvent(t,{detail:n,bubbles:!0,composed:!0}))});s.$refs||(s.$refs=t.$refs??/* @__PURE__ */new Map);return h(s,t)}(n,t);if(!o)return;const s=["ui:for",":for"],i=w(n,s);if(i)return y(n,s),void function(e,t,n){if(!e.parentNode)return;const o=/^\s*(.+)\s+(?:in|of)\s+(.+)\s*$/.exec(t);if(!o)return console.warn(`[jsweb/ui] Invalid ui:for expression: ${t}`);const[,s,i]=o,c=["ui:key",":key"],r=w(e,c);y(e,c);const u=crypto.randomUUID(),a=document.createComment(` ui:for ${u} `);e.replaceWith(a);let d=[];b(a,()=>{const t=l(i,n);if(!Array.isArray(t))return d.forEach(e=>{e.el.remove(),p(e.el)}),void(d=[]);const o=[],c=/* @__PURE__ */new Map;d.forEach(e=>c.set(e.key,e)),t.forEach((t,i)=>{let u=i;if(r){const e=h({[s]:t,$index:i},n);u=l(r,e)}let a=c.get(u);if(a)a.scope[s]=t,a.scope.$index=i,a.scope.$key=u,a.scope.$el=a.el,c.delete(u);else{const o=e.cloneNode(!0),c=f({[s]:t,$index:i,$key:u,$el:o});m(o,h(c,n)),a={key:u,el:o,scope:c}}o.push(a)}),c.forEach(e=>{e.el.remove(),p(e.el)});let u=a.nextSibling;o.forEach(e=>{u===e.el?u=u.nextSibling:a.parentNode?.insertBefore(e.el,u)}),d=o})}(n,i,o);const c=["ui:if",":if"],r=w(n,c);r&&(y(n,c),function(e,t,n){if(!e.parentNode)return;const o=crypto.randomUUID(),s=document.createComment(` ui:if ${o} `);e.before(s),b(s,()=>{l(t,n)?e.parentNode||s.parentNode?.insertBefore(e,s.nextSibling):e.parentNode&&e.remove()})}(n,r,o)),function(e,t){const n=Array.from(e.attributes);for(const o of n){const{name:n,value:s}=o,i=["ui:text",":text"].includes(n),c=["ui:bind",":bind"].includes(n),r=["ui:class",":class"].includes(n),f=["ui:style",":style"].includes(n),u=["ui:ref",":ref"].includes(n),a=n.startsWith("ui:")||n.startsWith(":"),l=n.startsWith("ui@")||n.startsWith("@");i?($(e,s,t),e.removeAttribute(n)):c?(E(e,s,t),e.removeAttribute(n)):r?(S(e,s,t),e.removeAttribute(n)):f?(x(e,s,t),e.removeAttribute(n)):u?(g(e,s,t),e.removeAttribute(n)):a?(A(e,n.split(":").pop(),s,t),e.removeAttribute(n)):l&&(M(e,n,s,t),e.removeAttribute(n))}}(n,o);const u=Array.from(n.childNodes);for(const f of u)m(f,o)}function v(e,t){const n="string"==typeof e?document.querySelector(e):e;if(n){const e=t??{};e.$el=n,e.$emit||(e.$emit=(e,t)=>{n.dispatchEvent(new CustomEvent(e,{detail:t,bubbles:!0,composed:!0}))}),e.$refs||(e.$refs=/* @__PURE__ */new Map),m(n,e)}else console.warn("[jsweb/ui] Element not found:",e)}function b(e,t){const n=i(t),o=e;o._effects??=[],o._effects.push(n.stop)}function w(e,t){for(const n of t){const t=e.getAttribute(n);if(null!==t)return t}return null}function y(e,t){for(const n of t)e.removeAttribute(n)}function g(e,t,n){const o=t.trim().replace(/^['"]|['"]$/g,"");if(!o)return;const s=n.$refs;if(!s)return;const i=n.$key;if(void 0!==i){let t=s.get(o);t instanceof Map||(t=/* @__PURE__ */new Map,s.set(o,t)),t.set(i,e)}else s.set(o,e);const c=e;c._effects??=[],c._effects.push(()=>{if(void 0!==i){const e=s.get(o);e instanceof Map&&(e.delete(i),0===e.size&&s.delete(o))}else s.get(o)===e&&s.delete(o)})}function $(e,t,n){b(e,()=>{const o=l(t,n);e.textContent=null!=o?String(o):""})}function E(e,t,n){const o=e instanceof HTMLInputElement&&"checkbox"===e.type,s=e instanceof HTMLInputElement&&"radio"===e.type;b(e,()=>{const i=l(t,n);if(o)e.checked=!!i;else if(s)e.checked=e.value===String(i);else{e.value=null==i?"":String(i)}});const i=o||s||e instanceof HTMLSelectElement?"change":"input";e.addEventListener(i,e=>{d(e,`${t} = ${"$event.target."+(o?"checked":"value")}`,n)})}function A(e,t,n,o){b(e,()=>{const s=l(n,o);null==s||!1===s?e.removeAttribute(t):!0===s?e.setAttribute(t,""):e.setAttribute(t,String(s))})}function S(e,t,n){let o=/* @__PURE__ */new Set;b(e,()=>{const s=l(t,n),i=/* @__PURE__ */new Set,c=e=>e&&i.add(e),r=e=>e.split(/\s+/).forEach(c);"string"==typeof s?r(s):Array.isArray(s)?s.flat().forEach(e=>{"string"==typeof e&&r(e)}):"object"==typeof s&&null!==s&&Object.entries(s).forEach(([e,t])=>{t&&r(e)}),o.forEach(t=>{i.has(t)||e.classList.remove(t)}),i.forEach(t=>{o.has(t)||e.classList.add(t)}),o=i})}function x(e,t,n){let o={};b(e,()=>{const s=l(t,n),i="object"==typeof s&&null!==s?s:{};for(const t in o)t in i||(e.style[t]="");for(const t in i)o[t]!==i[t]&&(e.style[t]=i[t]);o={...i}})}function M(e,t,n,o){const[s,...i]=t.split("@").pop().split("."),c=i.includes("outside"),r=c?document:e,f=t=>{if(!e.isConnected)return;const s=t.target instanceof Node;c&&s&&e.contains(t.target)||i.includes("self")&&t.target!==e||(i.includes("prevent")&&t.preventDefault(),i.includes("stop")&&t.stopPropagation(),d(t,n,o))};if(r.addEventListener(s,f),c){const t=e;t._effects??=[],t._effects.push(()=>r.removeEventListener(s,f))}}if("undefined"!=typeof window){const e=window;e.jsweb=e.jsweb||{},e.jsweb.ui={createScope:v,reactive:f,watch:a,Scope:r}}export{r as Scope,v as createScope,f as reactive,a as watch};
|
|
2
|
+
//# sourceMappingURL=index.es.js.map
|